@dingtalk-real-ai/dingtalk-connector 0.7.6 → 0.7.8

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.
Files changed (49) hide show
  1. package/.github/workflows/issue-to-AI-table.yml +52 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +23 -21
  4. package/docs/RELEASE_NOTES_V0.7.7.md +122 -0
  5. package/docs/RELEASE_NOTES_V0.7.8.md +101 -0
  6. package/openclaw.plugin.json +1 -1
  7. package/package.json +13 -5
  8. package/plugin.ts +210 -19
  9. package/tests/README.md +54 -0
  10. package/tests/ai-card/PLAN.md +54 -0
  11. package/tests/ai-card/ai-card.test.ts +372 -0
  12. package/tests/audio/PLAN.md +64 -0
  13. package/tests/audio/audio.test.ts +283 -0
  14. package/tests/bindings/PLAN.md +99 -0
  15. package/tests/bindings/bindings.test.ts +191 -0
  16. package/tests/card-update/PLAN.md +29 -0
  17. package/tests/card-update/card-update.test.ts +127 -0
  18. package/tests/config-token/PLAN.md +94 -0
  19. package/tests/config-token/config-token.test.ts +153 -0
  20. package/tests/core/PLAN.md +65 -0
  21. package/tests/core/core.test.ts +286 -0
  22. package/tests/deliver-payload/PLAN.md +59 -0
  23. package/tests/deliver-payload/deliver-payload.test.ts +91 -0
  24. package/tests/download/PLAN.md +47 -0
  25. package/tests/download/download.test.ts +261 -0
  26. package/tests/file-markers/PLAN.md +74 -0
  27. package/tests/file-markers/file-markers.test.ts +105 -0
  28. package/tests/index.ts +129 -0
  29. package/tests/integration/PLAN.md +65 -0
  30. package/tests/integration/integration.test.ts +232 -0
  31. package/tests/mcp-tools/PLAN.md +67 -0
  32. package/tests/mcp-tools/mcp-tools.test.ts +327 -0
  33. package/tests/media/PLAN.md +37 -0
  34. package/tests/media/media.test.ts +50 -0
  35. package/tests/message-extract/PLAN.md +83 -0
  36. package/tests/message-extract/message-extract.test.ts +205 -0
  37. package/tests/proactive/PLAN.md +88 -0
  38. package/tests/proactive/proactive.test.ts +502 -0
  39. package/tests/prompts/PLAN.md +71 -0
  40. package/tests/prompts/prompts.test.ts +64 -0
  41. package/tests/send-message/PLAN.md +44 -0
  42. package/tests/send-message/send-message.test.ts +228 -0
  43. package/tests/session/PLAN.md +90 -0
  44. package/tests/session/session.test.ts +166 -0
  45. package/tests/upload/PLAN.md +72 -0
  46. package/tests/upload/upload.test.ts +390 -0
  47. package/tests/video/PLAN.md +118 -0
  48. package/tests/video/video.test.ts +40 -0
  49. package/vitest.config.ts +13 -0
package/plugin.ts CHANGED
@@ -553,17 +553,17 @@ async function extractVideoMetadata(
553
553
  const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
554
554
  ffmpeg.setFfmpegPath(ffmpegPath);
555
555
 
556
- return new Promise((resolve, reject) => {
556
+ return new Promise((resolve) => {
557
557
  ffmpeg.ffprobe(filePath, (err: any, metadata: any) => {
558
558
  if (err) {
559
559
  log?.error?.(`[DingTalk][Video] 提取元数据失败: ${err.message}`);
560
- return reject(err);
560
+ return resolve({ duration: 0, width: 0, height: 0 });
561
561
  }
562
562
 
563
563
  const videoStream = metadata.streams.find((s: any) => s.codec_type === 'video');
564
564
  if (!videoStream) {
565
565
  log?.warn?.(`[DingTalk][Video] 未找到视频流`);
566
- return resolve(null);
566
+ return resolve({ duration: 0, width: 0, height: 0 });
567
567
  }
568
568
 
569
569
  const result = {
@@ -578,7 +578,7 @@ async function extractVideoMetadata(
578
578
  });
579
579
  } catch (err: any) {
580
580
  log?.error?.(`[DingTalk][Video] ffprobe 失败: ${err.message}`);
581
- return null;
581
+ return { duration: 0, width: 0, height: 0 };
582
582
  }
583
583
  }
584
584
 
@@ -596,7 +596,7 @@ async function extractVideoThumbnail(
596
596
  const path = await import('path');
597
597
  ffmpeg.setFfmpegPath(ffmpegPath);
598
598
 
599
- return new Promise((resolve, reject) => {
599
+ return new Promise((resolve) => {
600
600
  ffmpeg(videoPath)
601
601
  .screenshots({
602
602
  count: 1,
@@ -611,7 +611,7 @@ async function extractVideoThumbnail(
611
611
  })
612
612
  .on('error', (err: any) => {
613
613
  log?.error?.(`[DingTalk][Video] 封面生成失败: ${err.message}`);
614
- reject(err);
614
+ resolve(null);
615
615
  });
616
616
  });
617
617
  } catch (err: any) {
@@ -920,7 +920,7 @@ async function extractAudioDuration(
920
920
  log?: any,
921
921
  ): Promise<number | null> {
922
922
  try {
923
- const { execFile } = require('child_process');
923
+ const { execFile } = await import('child_process');
924
924
  const ffprobeBin = getFfprobePath();
925
925
 
926
926
  return new Promise((resolve) => {
@@ -1103,7 +1103,7 @@ async function processFileMarkers(
1103
1103
 
1104
1104
  const DINGTALK_API = 'https://api.dingtalk.com';
1105
1105
  const DINGTALK_OAPI = 'https://oapi.dingtalk.com';
1106
- const AI_CARD_TEMPLATE_ID = '382e4302-551d-4880-bf29-a30acfab2e71.schema';
1106
+ const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
1107
1107
 
1108
1108
  // flowStatus 值与 Python SDK AICardStatus 一致(cardParamMap 的值必须是字符串)
1109
1109
  const AICardStatus = {
@@ -1141,6 +1141,45 @@ async function createAICard(
1141
1141
  return createAICardForTarget(config, target, log);
1142
1142
  }
1143
1143
 
1144
+ /**
1145
+ * 确保 Markdown 表格前有空行,否则钉钉无法正确渲染表格。
1146
+ *
1147
+ * 逐行向前看:当前行像表头(含 `|`)且下一行是分隔行时,
1148
+ * 若前一行非空且非表格行,则在表头前插入空行。
1149
+ * 支持缩进表格(行首有空白字符)。
1150
+ */
1151
+ function ensureTableBlankLines(text: string): string {
1152
+ const lines = text.split('\n');
1153
+ const result: string[] = [];
1154
+
1155
+ // 匹配表格分隔行 (例如 | --- | --- | 或 --- | ---)
1156
+ const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/;
1157
+ // 匹配包含竖线的表格行
1158
+ const tableRowRegex = /^\s*\|?.*\|.*\|?\s*$/;
1159
+
1160
+ const isDivider = (line: string) => line.includes('|') && tableDividerRegex.test(line);
1161
+
1162
+ for (let i = 0; i < lines.length; i++) {
1163
+ const currentLine = lines[i];
1164
+ const nextLine = lines[i + 1] ?? '';
1165
+
1166
+ // 逻辑:
1167
+ // 1. 当前行看起来像表头(包含 |)
1168
+ // 2. 下一行是分隔行(---)
1169
+ // 3. 前一行不是空行且不是表格行
1170
+ if (
1171
+ tableRowRegex.test(currentLine) &&
1172
+ isDivider(nextLine) &&
1173
+ i > 0 && lines[i - 1].trim() !== '' && !tableRowRegex.test(lines[i - 1])
1174
+ ) {
1175
+ result.push('');
1176
+ }
1177
+
1178
+ result.push(currentLine);
1179
+ }
1180
+ return result.join('\n');
1181
+ }
1182
+
1144
1183
  // 流式更新 AI Card 内容
1145
1184
  async function streamAICard(
1146
1185
  card: AICardInstance,
@@ -1177,11 +1216,12 @@ async function streamAICard(
1177
1216
  }
1178
1217
 
1179
1218
  // 调用 streaming API 更新内容
1219
+ const fixedContent = ensureTableBlankLines(content);
1180
1220
  const body = {
1181
1221
  outTrackId: card.cardInstanceId,
1182
1222
  guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1183
1223
  key: 'msgContent',
1184
- content: content,
1224
+ content: fixedContent,
1185
1225
  isFull: true, // 全量替换
1186
1226
  isFinalize: finished,
1187
1227
  isError: false,
@@ -1205,10 +1245,11 @@ async function finishAICard(
1205
1245
  content: string,
1206
1246
  log?: any,
1207
1247
  ): Promise<void> {
1208
- log?.info?.(`[DingTalk][AICard] 开始 finish,最终内容长度=${content.length}`);
1248
+ const fixedContent = ensureTableBlankLines(content);
1249
+ log?.info?.(`[DingTalk][AICard] 开始 finish,最终内容长度=${fixedContent.length}`);
1209
1250
 
1210
1251
  // 1. 先用最终内容关闭流式通道(isFinalize=true),确保卡片显示替换后的内容
1211
- await streamAICard(card, content, true, log);
1252
+ await streamAICard(card, fixedContent, true, log);
1212
1253
 
1213
1254
  // 2. 更新卡片状态为 FINISHED
1214
1255
  const body = {
@@ -1216,7 +1257,7 @@ async function finishAICard(
1216
1257
  cardData: {
1217
1258
  cardParamMap: {
1218
1259
  flowStatus: AICardStatus.FINISHED,
1219
- msgContent: content,
1260
+ msgContent: fixedContent,
1220
1261
  staticMsgContent: '',
1221
1262
  sys_full_json_obj: JSON.stringify({
1222
1263
  order: ['msgContent'], // 只声明实际使用的字段,避免部分客户端显示空占位
@@ -1377,6 +1418,8 @@ interface GatewayOptions {
1377
1418
  memoryUser?: string;
1378
1419
  /** 本地图片文件路径列表,用于 OpenClaw AgentMediaPayload */
1379
1420
  imageLocalPaths?: string[];
1421
+ /** 自定义 Gateway URL(如通过 Nginx 代理),用于 TLS 等场景 */
1422
+ gatewayBaseUrl?: string;
1380
1423
  /** 会话类型:'direct'(单聊)或 'group'(群聊),用于 bindings 匹配 */
1381
1424
  peerKind?: 'direct' | 'group';
1382
1425
  /** 发送者 ID,用于 bindings 匹配 */
@@ -1386,10 +1429,13 @@ interface GatewayOptions {
1386
1429
  }
1387
1430
 
1388
1431
  async function* streamFromGateway(options: GatewayOptions, accountId: string): AsyncGenerator<string, void, unknown> {
1389
- const { userContent, systemPrompts, sessionKey, gatewayAuth, memoryUser, imageLocalPaths, peerKind, peerId, gatewayPort, log } = options;
1432
+ // 支持自定义 Gateway URL(如通过 Nginx 代理),用于 TLS 等场景
1433
+ const { userContent, systemPrompts, sessionKey, gatewayAuth, gatewayBaseUrl, memoryUser, imageLocalPaths, peerKind, peerId, gatewayPort, log } = options;
1390
1434
  const rt = getRuntime();
1391
1435
  const port = gatewayPort || rt.gateway?.port || 18789;
1392
- const gatewayUrl = `http://127.0.0.1:${port}/v1/chat/completions`;
1436
+ const gatewayUrl = gatewayBaseUrl
1437
+ ? `${gatewayBaseUrl}/v1/chat/completions`
1438
+ : `http://127.0.0.1:${port}/v1/chat/completions`;
1393
1439
 
1394
1440
  const messages: any[] = [];
1395
1441
  for (const prompt of systemPrompts) {
@@ -1693,7 +1739,7 @@ async function sendMarkdownMessage(
1693
1739
  options: any = {},
1694
1740
  ): Promise<any> {
1695
1741
  const token = await getAccessToken(config);
1696
- let text = markdown;
1742
+ let text = ensureTableBlankLines(markdown);
1697
1743
  if (options.atUserId) text = `${text} @${options.atUserId}`;
1698
1744
 
1699
1745
  const body: any = {
@@ -2230,7 +2276,7 @@ function buildMsgPayload(
2230
2276
  msgKey: 'sampleMarkdown',
2231
2277
  msgParam: {
2232
2278
  title: title || content.split('\n')[0].replace(/^[#*\s\->]+/, '').slice(0, 20) || 'Message',
2233
- text: content,
2279
+ text: ensureTableBlankLines(content),
2234
2280
  },
2235
2281
  };
2236
2282
  case 'link':
@@ -2380,7 +2426,7 @@ async function sendToUser(
2380
2426
  return { ok: false, error: 'Missing clientId or clientSecret', usedAICard: false };
2381
2427
  }
2382
2428
 
2383
- const userIdArray = Array.isArray(userIds) ? userIds : [userIds];
2429
+ const userIdArray = (Array.isArray(userIds) ? userIds : [userIds]).filter((id) => Boolean(id));
2384
2430
  if (userIdArray.length === 0) {
2385
2431
  return { ok: false, error: 'userIds cannot be empty', usedAICard: false };
2386
2432
  }
@@ -2495,6 +2541,62 @@ async function sendProactive(
2495
2541
  return { ok: false, error: 'Must specify userId, userIds, or openConversationId', usedAICard: false };
2496
2542
  }
2497
2543
 
2544
+ // ============ 消息处理中表情 ============
2545
+
2546
+ /** 在用户消息上贴 🤔思考中 表情,表示正在处理 */
2547
+ async function addEmotionReply(config: any, data: any, log?: any): Promise<void> {
2548
+ if (!data.msgId || !data.conversationId) return;
2549
+ try {
2550
+ const token = await getAccessToken(config);
2551
+ await axios.post(`${DINGTALK_API}/v1.0/robot/emotion/reply`, {
2552
+ robotCode: data.robotCode ?? config.clientId,
2553
+ openMsgId: data.msgId,
2554
+ openConversationId: data.conversationId,
2555
+ emotionType: 2,
2556
+ emotionName: '🤔思考中',
2557
+ textEmotion: {
2558
+ emotionId: '2659900',
2559
+ emotionName: '🤔思考中',
2560
+ text: '🤔思考中',
2561
+ backgroundId: 'im_bg_1',
2562
+ },
2563
+ }, {
2564
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
2565
+ timeout: 5_000,
2566
+ });
2567
+ log?.info?.(`[DingTalk][Emotion] 贴表情成功: msgId=${data.msgId}`);
2568
+ } catch (err: any) {
2569
+ log?.warn?.(`[DingTalk][Emotion] 贴表情失败(不影响主流程): ${err.message}`);
2570
+ }
2571
+ }
2572
+
2573
+ /** 撤回用户消息上的 🤔思考中 表情 */
2574
+ async function recallEmotionReply(config: any, data: any, log?: any): Promise<void> {
2575
+ if (!data.msgId || !data.conversationId) return;
2576
+ try {
2577
+ const token = await getAccessToken(config);
2578
+ await axios.post(`${DINGTALK_API}/v1.0/robot/emotion/recall`, {
2579
+ robotCode: data.robotCode ?? config.clientId,
2580
+ openMsgId: data.msgId,
2581
+ openConversationId: data.conversationId,
2582
+ emotionType: 2,
2583
+ emotionName: '🤔思考中',
2584
+ textEmotion: {
2585
+ emotionId: '2659900',
2586
+ emotionName: '🤔思考中',
2587
+ text: '🤔思考中',
2588
+ backgroundId: 'im_bg_1',
2589
+ },
2590
+ }, {
2591
+ headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
2592
+ timeout: 5_000,
2593
+ });
2594
+ log?.info?.(`[DingTalk][Emotion] 撤回表情成功: msgId=${data.msgId}`);
2595
+ } catch (err: any) {
2596
+ log?.warn?.(`[DingTalk][Emotion] 撤回表情失败(不影响主流程): ${err.message}`);
2597
+ }
2598
+ }
2599
+
2498
2600
  // ============ 核心消息处理 (AI Card Streaming) ============
2499
2601
 
2500
2602
  async function handleDingTalkMessage(params: {
@@ -2679,6 +2781,10 @@ async function handleDingTalkMessage(params: {
2679
2781
  }
2680
2782
  if (!userContent && imageLocalPaths.length === 0) return;
2681
2783
 
2784
+ // ===== 贴处理中表情 =====
2785
+ await addEmotionReply(dingtalkConfig, data, log);
2786
+
2787
+ try {
2682
2788
  // ===== 异步模式:立即回执 + 后台执行 + 主动推送结果 =====
2683
2789
  const asyncMode = dingtalkConfig.asyncMode === true;
2684
2790
  const proactiveTarget = isDirect
@@ -2709,6 +2815,7 @@ async function handleDingTalkMessage(params: {
2709
2815
  systemPrompts,
2710
2816
  sessionKey: sessionContextJson,
2711
2817
  gatewayAuth,
2818
+ gatewayBaseUrl: dingtalkConfig.gatewayBaseUrl,
2712
2819
  memoryUser,
2713
2820
  imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined,
2714
2821
  peerKind,
@@ -2786,6 +2893,7 @@ async function handleDingTalkMessage(params: {
2786
2893
  systemPrompts,
2787
2894
  sessionKey: sessionContextJson,
2788
2895
  gatewayAuth,
2896
+ gatewayBaseUrl: dingtalkConfig.gatewayBaseUrl,
2789
2897
  memoryUser,
2790
2898
  imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined,
2791
2899
  peerKind,
@@ -2870,6 +2978,7 @@ async function handleDingTalkMessage(params: {
2870
2978
  systemPrompts,
2871
2979
  sessionKey: sessionContextJson,
2872
2980
  gatewayAuth,
2981
+ gatewayBaseUrl: dingtalkConfig.gatewayBaseUrl,
2873
2982
  memoryUser,
2874
2983
  imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined,
2875
2984
  peerKind,
@@ -2909,6 +3018,10 @@ async function handleDingTalkMessage(params: {
2909
3018
  });
2910
3019
  }
2911
3020
  }
3021
+ } finally {
3022
+ // ===== 撤回处理中表情 =====
3023
+ await recallEmotionReply(dingtalkConfig, data, log);
3024
+ }
2912
3025
  }
2913
3026
 
2914
3027
  // ============ 钉钉文档 API ============
@@ -3253,6 +3366,7 @@ const dingtalkPlugin = {
3253
3366
  groupPolicy: { type: 'string', enum: ['open', 'allowlist'], default: 'open' },
3254
3367
  gatewayToken: { type: 'string', default: '', description: 'Gateway auth token (Bearer)' },
3255
3368
  gatewayPassword: { type: 'string', default: '', description: 'Gateway auth password (alternative to token)' },
3369
+ gatewayBaseUrl: { type: 'string', default: '', description: 'Custom Gateway URL (e.g., http://127.0.0.1:18788 for Nginx proxy to TLS Gateway)' },
3256
3370
  sessionTimeout: { type: 'number', default: 1800000, description: 'Session timeout in ms (default 30min)' },
3257
3371
  separateSessionByConversation: { type: 'boolean', default: true, description: '是否按单聊/群聊/群区分 session' },
3258
3372
  sharedMemoryAcrossConversations: { type: 'boolean', default: false, description: '单 agent 场景下是否共享记忆;false 时不同群聊、群聊与私聊记忆隔离' },
@@ -3282,7 +3396,10 @@ const dingtalkPlugin = {
3282
3396
  const config = getConfig(cfg);
3283
3397
  const id = accountId || DEFAULT_ACCOUNT_ID;
3284
3398
  if (config.accounts?.[id]) {
3285
- return { accountId: id, config: config.accounts[id], enabled: config.accounts[id].enabled !== false };
3399
+ // 合并 channel 级别配置(如 gatewayBaseUrl)到 account 配置
3400
+ const { accounts, ...channelConfig } = config;
3401
+ const mergedConfig = { ...channelConfig, ...config.accounts[id] };
3402
+ return { accountId: id, config: mergedConfig, enabled: config.accounts[id].enabled !== false };
3286
3403
  }
3287
3404
  // 没有 accounts 配置或找不到指定账号时,使用顶层配置
3288
3405
  return { accountId: DEFAULT_ACCOUNT_ID, config, enabled: config.enabled !== false };
@@ -3458,7 +3575,7 @@ const dingtalkPlugin = {
3458
3575
 
3459
3576
  // 【消息去重】检查是否已处理过该消息
3460
3577
  if (messageId && isMessageProcessed(messageId)) {
3461
- ctx.log?.warn?.(`[DingTalk] 检测到重复消息,跳过处理: messageId=${messageId}`);
3578
+ ctx.log?.warn?.(`[DingTalk][${account.accountId}] 检测到重复消息,跳过处理: messageId=${messageId}`);
3462
3579
  return;
3463
3580
  }
3464
3581
 
@@ -3859,3 +3976,77 @@ export {
3859
3976
  // 钉钉文档客户端
3860
3977
  DingtalkDocsClient,
3861
3978
  };
3979
+
3980
+ // ============ 测试辅助导出 ============
3981
+ // 仅用于单元测试,避免在业务代码中直接依赖内部实现细节
3982
+ export const __testables = {
3983
+ // Markdown 修正
3984
+ ensureTableBlankLines,
3985
+ // 会话 & 去重
3986
+ normalizeSlashCommand,
3987
+ buildSessionContext,
3988
+ isMessageProcessed,
3989
+ markMessageProcessed,
3990
+ cleanupProcessedMessages,
3991
+ // 配置 & Token
3992
+ getConfig,
3993
+ isConfigured,
3994
+ getAccessToken,
3995
+ getOapiAccessToken,
3996
+ getUnionId,
3997
+ // 媒体处理
3998
+ toLocalPath,
3999
+ processLocalImages,
4000
+ uploadMediaToDingTalk,
4001
+ downloadImageToFile,
4002
+ downloadMediaByCode,
4003
+ downloadFileByCode,
4004
+ // 视频处理
4005
+ extractVideoMetadata,
4006
+ extractVideoThumbnail,
4007
+ processVideoMarkers,
4008
+ sendVideoMessage,
4009
+ // 音频处理
4010
+ getFfprobePath,
4011
+ extractAudioDuration,
4012
+ sendAudioMessage,
4013
+ processAudioMarkers,
4014
+ isAudioFile,
4015
+ // 文件处理
4016
+ extractFileMarkers,
4017
+ sendFileMessage,
4018
+ processFileMarkers,
4019
+ // 消息内容提取
4020
+ extractMessageContent,
4021
+ // 消息发送
4022
+ sendMarkdownMessage,
4023
+ sendTextMessage,
4024
+ sendMessage,
4025
+ // 提示词与消息体
4026
+ buildMediaSystemPrompt,
4027
+ buildDeliverBody,
4028
+ buildMsgPayload,
4029
+ // AI Card
4030
+ createAICard,
4031
+ streamAICard,
4032
+ finishAICard,
4033
+ createAICardForTarget,
4034
+ sendFileProactive,
4035
+ sendAudioProactive,
4036
+ sendVideoProactive,
4037
+ sendAICardInternal,
4038
+ sendAICardToUser,
4039
+ sendAICardToGroup,
4040
+ // 主动消息
4041
+ sendNormalToUser,
4042
+ sendNormalToGroup,
4043
+ sendToUser,
4044
+ sendToGroup,
4045
+ sendProactive,
4046
+ // Bindings 解析(测试时需 mock getRuntime/fs/path/os)
4047
+ resolveAgentIdByBindings,
4048
+ /** 仅测试用:注入 runtime 使 resolveAgentIdByBindings 不抛错 */
4049
+ setRuntimeForTest(r: PluginRuntime | null) {
4050
+ runtime = r;
4051
+ },
4052
+ };
@@ -0,0 +1,54 @@
1
+ # plugin 测试说明
2
+
3
+ 本目录按**测试套件(模块)**划分,每个子目录包含:**测试代码**(`*.test.ts`)与**测试方案**(`PLAN.md`)。方案中给出模块职责、用例表(含输入/期望/说明)以及正确/错误输出原因,便于覆盖全部情况并扩展用例。
4
+
5
+ 其中大多数为**单元测试/契约测试**(基于 mock 的纯函数与 I/O 边界验证);`integration` 为**端到端集成测试**(需要真实钉钉凭证,默认跳过)。
6
+
7
+ ## 模块列表
8
+
9
+ | 目录 | 覆盖函数/能力 | 说明 |
10
+ |------|----------------|------|
11
+ | [session](./session/) | normalizeSlashCommand, buildSessionContext, isMessageProcessed, markMessageProcessed, cleanupProcessedMessages | 会话与消息去重 |
12
+ | [config-token](./config-token/) | getConfig, isConfigured, getAccessToken, getOapiAccessToken, getUnionId | 配置与 Token |
13
+ | [media](./media/) | toLocalPath, processLocalImages | 本地路径与图片后处理 |
14
+ | [video](./video/) | processVideoMarkers | 视频标记解析与控制流 |
15
+ | [message-extract](./message-extract/) | extractMessageContent | 钉钉消息内容提取(text/richText/picture/audio/video/file) |
16
+ | [file-markers](./file-markers/) | extractFileMarkers, isAudioFile | 文件标记解析与音频类型判断 |
17
+ | [prompts](./prompts/) | buildMediaSystemPrompt | 媒体相关系统提示词 |
18
+ | [deliver-payload](./deliver-payload/) | buildDeliverBody, buildMsgPayload | AI Card 投放体与普通消息体 |
19
+ | [bindings](./bindings/) | resolveAgentIdByBindings | OpenClaw bindings 解析(需 mock fs/path/os) |
20
+ | [ai-card](./ai-card/) | create/stream/finish AI Card, sendAICard* | AI Card 创建与流式更新(结构化返回 + 回退) |
21
+ | [card-update](./card-update/) | createAICard + stream/finish 状态机(回归集) | 轻量回归:目标选择与 INPUTING→streaming→FINISHED 状态机(避免与 ai-card/proactive 重复) |
22
+ | [send-message](./send-message/) | sendTextMessage, sendMarkdownMessage, sendMessage | 群机器人 webhook 发送(自动 text/markdown 判定) |
23
+ | [proactive](./proactive/) | buildMsgPayload, sendNormalTo*, sendTo*, sendProactive | 主动消息 API:消息体构造、AI Card 策略与回退 |
24
+ | [audio](./audio/) | isAudioFile, getFfprobePath, extractAudioDuration, processAudioMarkers, sendAudio* | 音频识别、时长解析与音频发送 |
25
+ | [upload](./upload/) | uploadMediaToDingTalk, download*/extractVideo*, process*Markers | 上传/下载/媒体处理与 marker 处理(多分支容错) |
26
+ | [download](./download/) | downloadImageToFile, downloadMediaByCode, downloadFileByCode | 下载与文件名/扩展名推断 |
27
+ | [mcp-tools](./mcp-tools/) | MCP 工具层:sendToUser/sendToGroup/sendProactive/uploadMedia... | 对外工具输入校验与行为一致性 |
28
+ | [core](./core/) | normalizeSlashCommand/getConfig/getAccessToken/... | 核心能力冒烟/回归集合(跨模块快速验证) |
29
+ | [integration](./integration/) | getAccessToken, sendToUser, AI Card, media upload | 端到端集成测试(需要真实环境变量) |
30
+
31
+ ## 运行方式
32
+
33
+ ```bash
34
+ npm test # 运行全部用例
35
+ npm run test:watch # 监听模式
36
+ npm run test:integration # 仅运行集成测试(需要环境变量)
37
+ ```
38
+
39
+ ## 扩展用例
40
+
41
+ - 各 `PLAN.md` 中均有用例表(序号 + 输入 + 期望 + 说明),可按表在对应 `*.test.ts` 中用 `it.each` 或单条 `it` 补充,实现“成百上千条”覆盖。
42
+ - 依赖真实外部环境(如钉钉 API、真实文件系统、ffmpeg/ffprobe)的路径,优先在单元测试中做 **mock + 契约断言**;需要验证真实链路时再放到 `integration`(或后续 E2E)中补充。
43
+
44
+ ## 测试编写约定(建议遵循)
45
+
46
+ - **模块职责优先**:每个 `tests/<suite>/` 只覆盖该域内的能力;不要在一个套件里测试“顺手能调到”的其它函数(避免重复与耦合)。
47
+ - **断言优先级**:
48
+ - **优先断言契约**:关键字段(如 `msgKey/msgParam/openSpaceId`)与错误返回结构(如 `ok=false` 时 `error`)。
49
+ - **避免断言实现细节**:例如随机生成的 id、时间戳、日志完整文本等。
50
+ - **Mock 外部依赖**:
51
+ - 网络:优先 `vi.mock('axios')` + hoisted mock,并按 URL 分流(token 获取与业务请求常共用 `axios.post`)。
52
+ - 文件系统:尽量用 `await import('fs')` 的代码路径以便在测试中 `vi.mock('fs')/vi.doMock('fs')` 生效。
53
+ - 外部工具(ffmpeg/ffprobe):测试中不依赖真实二进制与文件存在性;失败路径应可预测(返回 `null` 或默认值),必要时用集成测试覆盖真实链路。
54
+ - **避免共享状态污染**:若被测模块存在模块级缓存(如 token 缓存),在需要隔离的用例中使用 `vi.resetModules()` 后再 `import`。
@@ -0,0 +1,54 @@
1
+ # AI Card 模块测试方案
2
+
3
+ ## 1. 模块划分与职责
4
+
5
+ 本套件覆盖 AI Card 相关能力(创建/流式更新/结束/投放与回退),主要围绕以下通过 `plugin.__testables` 暴露的函数:
6
+
7
+ - **buildDeliverBody(cardInstanceId, target, robotCode)**:构建投放请求体(核心契约:`openSpaceId` 与 deliver model)。
8
+ - **createAICardForTarget(config, target, log?)**:通用创建+投放,返回 `{ cardInstanceId, accessToken, inputingStarted }` 或 null。
9
+ - **createAICard(config, data, log?)**:被动回复场景创建卡片(从 DingTalk 回调 `data` 推导 target 后委托给 createAICardForTarget)。
10
+ - **streamAICard(card, content, finished?, log?)**:流式更新(首次会切换 INPUTING;失败会 throw)。
11
+ - **finishAICard(card, content, log?)**:结束卡片(finalize streaming + 设置 FINISHED;FINISHED 写入失败会记录日志但不抛异常)。
12
+ - **sendAICardInternal / sendAICardToUser / sendAICardToGroup**:主动发送 AI Card(失败可按策略回退到普通消息)。
13
+
14
+ ## 2. 用例表(覆盖现有测试)
15
+
16
+ ### 2.1 buildDeliverBody
17
+
18
+ | 序号 | 场景 | mock/输入 | 期望 | 说明 |
19
+ |------|------|-----------|------|------|
20
+ | 1 | user target | target={type:'user', userId:'u1'} | openSpaceId 为 `dtv1.card//IM_ROBOT.u1` | deliver model 正确 |
21
+ | 2 | group target | target={type:'group', openConversationId:'c1'} | openSpaceId 为 `dtv1.card//IM_GROUP.c1` | deliver model 正确 |
22
+
23
+ ### 2.2 createAICardForTarget
24
+
25
+ | 序号 | 场景 | mock/输入 | 期望 | 说明 |
26
+ |------|------|-----------|------|------|
27
+ | 3 | user 创建投放成功 | POST instances + deliver 成功 | 返回 cardInstanceId/accessToken | 并投放到 IM_ROBOT |
28
+ | 4 | group 创建投放成功 | 同上 | 返回非空 | 并投放到 IM_GROUP |
29
+ | 5 | create 失败 | axios.post reject | 返回 null | 错误收敛 |
30
+ | 6 | deliver 失败 | deliver reject | 返回 null | 错误收敛 |
31
+
32
+ ### 2.3 streamAICard / finishAICard
33
+
34
+ | 序号 | 场景 | 输入 | 期望 | 说明 |
35
+ |------|------|------|------|------|
36
+ | 7 | 首次 stream | inputingStarted=false | 先 INPUTING 再 streaming | INPUTING 失败会 throw |
37
+ | 8 | 后续 stream | inputingStarted=true | 仅 streaming | 不重复 INPUTING |
38
+ | 9 | finish | - | finalize streaming + FINISHED | FINISHED 失败记录 error 不 throw |
39
+
40
+ ### 2.4 sendAICardInternal / sendAICardToUser / sendAICardToGroup
41
+
42
+ | 序号 | 场景 | mock/输入 | 期望 | 说明 |
43
+ |------|------|-----------|------|------|
44
+ | 10 | internal 用户发送成功 | gettoken + create + put 成功 | `ok=true` | 主流程 |
45
+ | 11 | internal 群发送成功 | 同上 | `ok=true` | 主流程 |
46
+ | 12 | internal 创建失败 | create 抛错 | `ok=false` + `error` | 错误返回 |
47
+ | 13 | toUser 带回退 | 卡片失败、普通消息成功(若实现有回退) | `ok=true` | 体现“回退不影响 ok” |
48
+ | 14 | toGroup 带回退 | 同上 | `ok=true` | |
49
+
50
+ ## 3. 预期正确输出与潜在错误
51
+
52
+ - **正确**:deliver body 的 `openSpaceId/robotCode` 契约稳定;createAICardForTarget 返回结构稳定;stream/finish 状态机顺序正确;外部错误被收敛为 null/throw(按函数契约)。
53
+ - **潜在错误原因**:openSpaceId 拼错(IM_ROBOT/IM_GROUP);deliver model 字段缺失;stream 首次未切 INPUTING;把应 throw 的路径吞掉导致上层误判成功。
54
+