@wu529778790/open-im 1.5.1-beta.1 → 1.5.2-beta.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 CHANGED
@@ -126,7 +126,8 @@ Claude 默认使用 Agent SDK,不依赖本地 `claude` 可执行文件;通
126
126
  "enabled": false,
127
127
  "allowedUserIds": [],
128
128
  "clientId": "YOUR_DINGTALK_CLIENT_ID",
129
- "clientSecret": "YOUR_DINGTALK_CLIENT_SECRET"
129
+ "clientSecret": "YOUR_DINGTALK_CLIENT_SECRET",
130
+ "cardTemplateId": "YOUR_DINGTALK_AI_CARD_TEMPLATE_ID"
130
131
  },
131
132
  "wechat": {
132
133
  "enabled": false,
@@ -159,6 +160,7 @@ Claude 默认使用 Agent SDK,不依赖本地 `claude` 可执行文件;通
159
160
  | `FEISHU_ALLOWED_USER_IDS` | 飞书白名单 |
160
161
  | `DINGTALK_CLIENT_ID` | 钉钉应用 Client ID / AppKey |
161
162
  | `DINGTALK_CLIENT_SECRET` | 钉钉应用 Client Secret / AppSecret |
163
+ | `DINGTALK_CARD_TEMPLATE_ID` | 钉钉 AI 卡片模板 ID,配置后启用单条流式回复 |
162
164
  | `DINGTALK_ALLOWED_USER_IDS` | 钉钉白名单 |
163
165
  | `WEWORK_CORP_ID` | 企业微信 Bot ID |
164
166
  | `WEWORK_SECRET` | 企业微信 Secret |
@@ -180,7 +182,13 @@ Claude 默认使用 Agent SDK,不依赖本地 `claude` 可执行文件;通
180
182
  - 企业微信:从 [企业微信管理后台](https://work.weixin.qq.com/) 获取 Bot ID 和 Secret
181
183
  - 微信:测试中,支持标准模式和 AGP/Qclaw 相关配置
182
184
 
183
- 说明:钉钉当前采用“Stream Mode 收消息 + OpenAPI 主动通知”的混合模式。会话内普通回复优先走 `sessionWebhook`,启动/关闭通知会发给最近一次已互动的钉钉会话;如果服务冷启动后还没有任何钉钉会话互动过,则没有可用目标可发。
185
+ 说明:钉钉当前采用“Stream Mode 收消息 + OpenAPI 发送消息”的混合模式。
186
+
187
+ - 会话内普通文本回复默认走 `sessionWebhook`
188
+ - 若配置了 `cardTemplateId`,会尝试 AI 助理 `prepare/update/finish` 流式卡片;失败则 fallback 为普通文本(自定义机器人/普通群场景下互动卡片 API 报 param.error,暂不支持单条流式更新)
189
+ - 启动/关闭通知会发给最近一次已互动的钉钉会话;如果服务冷启动后还没有任何钉钉会话互动过,则没有可用目标可发
190
+
191
+ 钉钉 AI 卡片模板:已适配官方「搜索结果卡片」模板,使用变量 `lastMessage`、`content`、`resources`、`users`、`flowStatus`。若使用该模板,无需修改模板即可实现流式更新。
184
192
 
185
193
  ## IM 内命令
186
194
 
@@ -217,6 +225,8 @@ Claude 默认使用 Agent SDK,不依赖本地 `claude` 可执行文件;通
217
225
 
218
226
  **钉钉无法回复**:确认应用已启用机器人 Stream Mode,并检查 `DINGTALK_CLIENT_ID`、`DINGTALK_CLIENT_SECRET` 或 `platforms.dingtalk` 配置是否正确。
219
227
 
228
+ **钉钉没有流式更新**:prepare 失败时 fallback 为普通文本回复。自定义机器人/普通群场景下,AI 助理和互动卡片 API 均不可用,仅支持单条文本回复。
229
+
220
230
  **Cursor 报 `Authentication required`**:先执行 `agent login`,或在 `env` 中设置 `CURSOR_API_KEY`。
221
231
 
222
232
  **Codex 报 `stream disconnected` / `error sending request`**:无法访问 `chatgpt.com`,请配置 `tools.codex.proxy` 或环境变量 `CODEX_PROXY`。
package/dist/config.d.ts CHANGED
@@ -19,6 +19,7 @@ export interface Config {
19
19
  weworkWsUrl?: string;
20
20
  dingtalkClientId?: string;
21
21
  dingtalkClientSecret?: string;
22
+ dingtalkCardTemplateId?: string;
22
23
  allowedUserIds: string[];
23
24
  telegramAllowedUserIds: string[];
24
25
  feishuAllowedUserIds: string[];
@@ -69,6 +70,7 @@ export interface Config {
69
70
  dingtalk?: {
70
71
  enabled: boolean;
71
72
  allowedUserIds: string[];
73
+ cardTemplateId?: string;
72
74
  };
73
75
  };
74
76
  }
package/dist/config.js CHANGED
@@ -248,6 +248,8 @@ export function loadConfig() {
248
248
  fileDingtalk?.clientId;
249
249
  const dingtalkClientSecret = process.env.DINGTALK_CLIENT_SECRET ??
250
250
  fileDingtalk?.clientSecret;
251
+ const dingtalkCardTemplateId = process.env.DINGTALK_CARD_TEMPLATE_ID ??
252
+ fileDingtalk?.cardTemplateId;
251
253
  // 2. 计算启用平台
252
254
  const enabledPlatforms = [];
253
255
  const telegramEnabledFlag = fileTelegram?.enabled;
@@ -578,10 +580,12 @@ export function loadConfig() {
578
580
  ? {
579
581
  enabled: true,
580
582
  allowedUserIds: dingtalkAllowedUserIds,
583
+ cardTemplateId: dingtalkCardTemplateId,
581
584
  }
582
585
  : {
583
586
  enabled: false,
584
587
  allowedUserIds: dingtalkAllowedUserIds,
588
+ cardTemplateId: dingtalkCardTemplateId,
585
589
  },
586
590
  };
587
591
  return {
@@ -602,6 +606,7 @@ export function loadConfig() {
602
606
  weworkWsUrl: weworkWsUrl,
603
607
  dingtalkClientId: dingtalkClientId ?? '',
604
608
  dingtalkClientSecret: dingtalkClientSecret ?? '',
609
+ dingtalkCardTemplateId: dingtalkCardTemplateId ?? '',
605
610
  allowedUserIds,
606
611
  telegramAllowedUserIds,
607
612
  feishuAllowedUserIds,
@@ -15,3 +15,4 @@ export declare const MAX_FEISHU_MESSAGE_LENGTH = 4000;
15
15
  /** CardKit 流式内容最大长度(卡片上限约 30KB,留余量) */
16
16
  export declare const MAX_STREAMING_CONTENT_LENGTH = 25000;
17
17
  export declare const MAX_WEWORK_MESSAGE_LENGTH = 2048;
18
+ export declare const MAX_DINGTALK_MESSAGE_LENGTH = 2048;
package/dist/constants.js CHANGED
@@ -36,3 +36,4 @@ export const MAX_FEISHU_MESSAGE_LENGTH = 4000;
36
36
  /** CardKit 流式内容最大长度(卡片上限约 30KB,留余量) */
37
37
  export const MAX_STREAMING_CONTENT_LENGTH = 25000;
38
38
  export const MAX_WEWORK_MESSAGE_LENGTH = 2048;
39
+ export const MAX_DINGTALK_MESSAGE_LENGTH = 2048;
@@ -1,6 +1,13 @@
1
1
  import { type DWClientDownStream } from 'dingtalk-stream';
2
2
  import type { Config } from '../config.js';
3
3
  import type { DingTalkActiveTarget } from '../shared/active-chats.js';
4
+ export interface DingTalkStreamingTarget {
5
+ chatId: string;
6
+ conversationType?: string;
7
+ senderStaffId?: string;
8
+ senderId?: string;
9
+ robotCode?: string;
10
+ }
4
11
  export declare function registerSessionWebhook(chatId: string, sessionWebhook: string): void;
5
12
  export declare function sendText(chatId: string, content: string): Promise<unknown>;
6
13
  export declare function sendMarkdown(chatId: string, title: string, text: string): Promise<unknown>;
@@ -8,3 +15,14 @@ export declare function ackMessage(messageId: string, result?: unknown): void;
8
15
  export declare function initDingTalk(cfg: Config, eventHandler: (data: DWClientDownStream) => Promise<void>): Promise<void>;
9
16
  export declare function stopDingTalk(): void;
10
17
  export declare function sendProactiveText(target: string | DingTalkActiveTarget, content: string): Promise<void>;
18
+ export declare function prepareStreamingCard(target: string | DingTalkStreamingTarget, templateId: string, cardData: Record<string, unknown>): Promise<string>;
19
+ export declare function updateStreamingCard(conversationToken: string, templateId: string, cardData: Record<string, unknown>): Promise<void>;
20
+ export declare function finishStreamingCard(conversationToken: string): Promise<void>;
21
+ /** 创建并投放卡片(卡片平台 API,支持普通群流式更新) */
22
+ export declare function createAndDeliverCard(target: DingTalkStreamingTarget, templateId: string, outTrackId: string, cardData: Record<string, unknown>): Promise<void>;
23
+ /** 更新卡片实例(用于流式更新) */
24
+ export declare function updateCardInstance(outTrackId: string, cardData: Record<string, unknown>): Promise<void>;
25
+ /** 互动卡片普通版:发送(用于 prepare 失败时的 fallback 流式) */
26
+ export declare function sendRobotInteractiveCard(target: DingTalkStreamingTarget, cardBizId: string, cardData: Record<string, unknown>): Promise<void>;
27
+ /** 互动卡片普通版:更新(单条消息流式更新) */
28
+ export declare function updateRobotInteractiveCard(cardBizId: string, cardData: Record<string, unknown>): Promise<void>;
@@ -2,10 +2,12 @@ import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream';
2
2
  import { createLogger } from '../logger.js';
3
3
  const log = createLogger('DingTalk');
4
4
  const DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com';
5
+ const DINGTALK_OAPI_BASE = 'https://oapi.dingtalk.com';
5
6
  const TEXT_MSG_KEY = 'sampleText';
6
7
  let client = null;
7
8
  let messageHandler = null;
8
9
  const sessionWebhookByChat = new Map();
10
+ const unionIdByUserId = new Map();
9
11
  function getClient() {
10
12
  if (!client) {
11
13
  throw new Error('DingTalk client not initialized');
@@ -58,6 +60,12 @@ export async function sendMarkdown(chatId, title, text) {
58
60
  },
59
61
  });
60
62
  }
63
+ function buildAiCardContent(templateId, cardData) {
64
+ return JSON.stringify({
65
+ templateId,
66
+ cardData,
67
+ });
68
+ }
61
69
  function getRobotCode(target) {
62
70
  if (!target.robotCode) {
63
71
  throw new Error('DingTalk proactive target is missing robotCode');
@@ -109,9 +117,72 @@ async function callOpenApi(path, body) {
109
117
  : text;
110
118
  throw new Error(`DingTalk OpenAPI business error: ${String(errorCode)} ${errorMessage}`);
111
119
  }
120
+ async function callOapi(path, body) {
121
+ const accessToken = await getClient().getAccessToken();
122
+ const res = await fetch(`${DINGTALK_OAPI_BASE}${path}?access_token=${encodeURIComponent(String(accessToken))}`, {
123
+ method: 'POST',
124
+ headers: {
125
+ 'content-type': 'application/json',
126
+ },
127
+ body: JSON.stringify(body),
128
+ signal: AbortSignal.timeout(30000),
129
+ });
130
+ const text = await res.text();
131
+ if (!res.ok) {
132
+ throw new Error(`DingTalk OAPI failed: ${res.status} ${text}`);
133
+ }
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(text);
137
+ }
138
+ catch {
139
+ throw new Error(`DingTalk OAPI returned non-JSON response: ${text}`);
140
+ }
141
+ const errorCode = parsed.errcode;
142
+ if (errorCode === 0 || errorCode === '0' || errorCode === undefined) {
143
+ return parsed;
144
+ }
145
+ const errorMessage = typeof parsed.errmsg === 'string'
146
+ ? parsed.errmsg
147
+ : typeof parsed.message === 'string'
148
+ ? parsed.message
149
+ : text;
150
+ throw new Error(`DingTalk OAPI business error: ${String(errorCode)} ${errorMessage}`);
151
+ }
112
152
  function normalizeConversationType(type) {
113
153
  return type?.trim().toLowerCase();
114
154
  }
155
+ function isSingleConversation(type) {
156
+ const normalizedType = normalizeConversationType(type);
157
+ return (normalizedType === '0' ||
158
+ normalizedType === 'single' ||
159
+ normalizedType === 'singlechat' ||
160
+ normalizedType === 'oto');
161
+ }
162
+ function isGroupConversation(type) {
163
+ const normalizedType = normalizeConversationType(type);
164
+ return (normalizedType === '1' ||
165
+ normalizedType === '2' ||
166
+ normalizedType === 'group' ||
167
+ normalizedType === 'groupchat');
168
+ }
169
+ async function resolveUnionIdByUserId(userId) {
170
+ if (!userId)
171
+ return undefined;
172
+ const cached = unionIdByUserId.get(userId);
173
+ if (cached)
174
+ return cached;
175
+ const result = await callOapi('/topapi/v2/user/get', {
176
+ userid: userId,
177
+ language: 'zh_CN',
178
+ });
179
+ const unionId = result.result?.unionid;
180
+ if (typeof unionId === 'string' && unionId.length > 0) {
181
+ unionIdByUserId.set(userId, unionId);
182
+ return unionId;
183
+ }
184
+ return undefined;
185
+ }
115
186
  function buildProactiveAttempts(target, content) {
116
187
  const robotCode = getRobotCode(target);
117
188
  const payload = buildTextPayload(content);
@@ -208,6 +279,7 @@ export function stopDingTalk() {
208
279
  }
209
280
  finally {
210
281
  sessionWebhookByChat.clear();
282
+ unionIdByUserId.clear();
211
283
  client = null;
212
284
  messageHandler = null;
213
285
  log.info('DingTalk client stopped');
@@ -230,10 +302,290 @@ export async function sendProactiveText(target, content) {
230
302
  }
231
303
  catch (err) {
232
304
  lastError = err;
233
- log.warn(`DingTalk proactive ${attempt.label} send failed:`, err);
305
+ const msg = err instanceof Error ? err.message : String(err);
306
+ if (msg.includes('robot') || msg.includes('resource.not.found')) {
307
+ log.debug(`DingTalk proactive ${attempt.label} send failed:`, err);
308
+ }
309
+ else {
310
+ log.warn(`DingTalk proactive ${attempt.label} send failed:`, err);
311
+ }
234
312
  }
235
313
  }
236
314
  throw lastError instanceof Error
237
315
  ? lastError
238
316
  : new Error(`DingTalk proactive send failed for chat ${target.chatId}`);
239
317
  }
318
+ export async function prepareStreamingCard(target, templateId, cardData) {
319
+ const normalizedTarget = typeof target === 'string' ? { chatId: target } : target;
320
+ const contentType = 'ai_card';
321
+ const content = buildAiCardContent(templateId, cardData);
322
+ const attempts = [];
323
+ log.debug(`DingTalk prepare: conversationType=${normalizedTarget.conversationType ?? 'undefined'}, senderStaffId=${normalizedTarget.senderStaffId ?? 'undefined'}`);
324
+ if (isSingleConversation(normalizedTarget.conversationType)) {
325
+ let unionId;
326
+ try {
327
+ unionId = await resolveUnionIdByUserId(normalizedTarget.senderStaffId);
328
+ }
329
+ catch (err) {
330
+ log.debug('Failed to resolve DingTalk unionId from senderStaffId:', err);
331
+ }
332
+ if (unionId) {
333
+ attempts.push({
334
+ label: 'single-unionid',
335
+ body: { unionId, contentType, content },
336
+ });
337
+ }
338
+ if (normalizedTarget.chatId) {
339
+ attempts.push({
340
+ label: 'single-chatid',
341
+ body: { openConversationId: normalizedTarget.chatId, contentType, content },
342
+ });
343
+ }
344
+ }
345
+ else if (isGroupConversation(normalizedTarget.conversationType)) {
346
+ // 群聊时也优先尝试 unionId:部分场景下 conversationType 可能误报,或单聊被识别为群聊
347
+ let unionId;
348
+ try {
349
+ unionId = await resolveUnionIdByUserId(normalizedTarget.senderStaffId);
350
+ }
351
+ catch (err) {
352
+ log.debug('Failed to resolve DingTalk unionId for group (fallback):', err);
353
+ }
354
+ if (unionId) {
355
+ attempts.push({
356
+ label: 'group-unionid',
357
+ body: { unionId, contentType, content },
358
+ });
359
+ }
360
+ if (normalizedTarget.chatId) {
361
+ attempts.push({
362
+ label: 'group-chatid',
363
+ body: { openConversationId: normalizedTarget.chatId, contentType, content },
364
+ });
365
+ }
366
+ }
367
+ else {
368
+ let unionId;
369
+ try {
370
+ unionId = await resolveUnionIdByUserId(normalizedTarget.senderStaffId);
371
+ }
372
+ catch (err) {
373
+ log.debug('Failed to resolve DingTalk unionId for unknown conversation type:', err);
374
+ }
375
+ if (unionId) {
376
+ attempts.push({
377
+ label: 'unknown-unionid',
378
+ body: { unionId, contentType, content },
379
+ });
380
+ }
381
+ if (normalizedTarget.chatId) {
382
+ attempts.push({
383
+ label: 'unknown-chatid',
384
+ body: { openConversationId: normalizedTarget.chatId, contentType, content },
385
+ });
386
+ }
387
+ }
388
+ if (attempts.length === 0) {
389
+ throw new Error('DingTalk prepare target is incomplete');
390
+ }
391
+ let result;
392
+ let lastError;
393
+ for (const attempt of attempts) {
394
+ try {
395
+ result = await callOpenApi('/v1.0/aiInteraction/prepare', attempt.body);
396
+ break;
397
+ }
398
+ catch (err) {
399
+ lastError = err;
400
+ log.debug(`DingTalk prepare attempt failed (${attempt.label}):`, err);
401
+ }
402
+ }
403
+ if (!result) {
404
+ throw lastError instanceof Error ? lastError : new Error('DingTalk prepare failed');
405
+ }
406
+ const token = result.result?.conversationToken;
407
+ if (typeof token !== 'string' || token.length === 0) {
408
+ throw new Error(`DingTalk prepare did not return conversationToken: ${JSON.stringify(result)}`);
409
+ }
410
+ return token;
411
+ }
412
+ export async function updateStreamingCard(conversationToken, templateId, cardData) {
413
+ const result = await callOpenApi('/v1.0/aiInteraction/update', {
414
+ conversationToken,
415
+ contentType: 'ai_card',
416
+ content: buildAiCardContent(templateId, cardData),
417
+ });
418
+ const success = result.result?.success;
419
+ if (success === false) {
420
+ throw new Error(`DingTalk update returned success=false: ${JSON.stringify(result)}`);
421
+ }
422
+ }
423
+ export async function finishStreamingCard(conversationToken) {
424
+ const result = await callOpenApi('/v1.0/aiInteraction/finish', {
425
+ conversationToken,
426
+ });
427
+ const success = result.result?.success;
428
+ if (success === false) {
429
+ throw new Error(`DingTalk finish returned success=false: ${JSON.stringify(result)}`);
430
+ }
431
+ }
432
+ /** 创建并投放卡片(卡片平台 API,支持普通群流式更新) */
433
+ export async function createAndDeliverCard(target, templateId, outTrackId, cardData) {
434
+ const { chatId, robotCode, conversationType, senderStaffId } = target;
435
+ if (!robotCode) {
436
+ throw new Error('DingTalk robotCode required for createAndDeliver');
437
+ }
438
+ const isSingle = isSingleConversation(conversationType);
439
+ const cardParamMap = buildCardParamMap(cardData);
440
+ if (!cardParamMap.content && !cardParamMap.lastMessage)
441
+ cardParamMap.content = '...';
442
+ const lastMsg = String(cardData.lastMessage ?? cardData.displayText ?? cardData.content ?? cardData.title ?? 'AI').slice(0, 50);
443
+ const body = {
444
+ userId: senderStaffId ?? 'system',
445
+ cardTemplateId: templateId,
446
+ outTrackId,
447
+ cardData: { cardParamMap },
448
+ };
449
+ if (isSingle && senderStaffId) {
450
+ body.openSpaceId = `dtv1.card//im_robot.${senderStaffId}`;
451
+ body.imRobotOpenSpaceModel = {
452
+ lastMessageI18n: { zh_CN: lastMsg },
453
+ searchSupport: { searchIcon: '', searchTypeName: '消息', searchDesc: '' },
454
+ notification: { alertContent: lastMsg },
455
+ };
456
+ body.imRobotOpenDeliverModel = { spaceType: 'IM_ROBOT' };
457
+ }
458
+ else {
459
+ body.openSpaceId = `dtv1.card//im_group.${chatId}`;
460
+ body.imGroupOpenSpaceModel = {
461
+ lastMessageI18n: { zh_CN: lastMsg },
462
+ searchSupport: { searchIcon: '', searchTypeName: '消息', searchDesc: '' },
463
+ notification: { alertContent: lastMsg },
464
+ };
465
+ body.imGroupOpenDeliverModel = {
466
+ robotCode,
467
+ atUserIds: {},
468
+ recipients: [],
469
+ };
470
+ }
471
+ await callOpenApiWithMethod('POST', '/v1.0/card/instances/createAndDeliver', body);
472
+ }
473
+ /** 将 cardData 转为 cardParamMap(对象/数组需 JSON 序列化) */
474
+ function buildCardParamMap(cardData) {
475
+ const cardParamMap = {};
476
+ for (const [k, v] of Object.entries(cardData)) {
477
+ if (v === undefined || v === null)
478
+ continue;
479
+ if (typeof v === 'object') {
480
+ cardParamMap[k] = JSON.stringify(v);
481
+ }
482
+ else {
483
+ cardParamMap[k] = String(v);
484
+ }
485
+ }
486
+ return cardParamMap;
487
+ }
488
+ /** 更新卡片实例(用于流式更新) */
489
+ export async function updateCardInstance(outTrackId, cardData) {
490
+ const cardParamMap = buildCardParamMap(cardData);
491
+ await callOpenApiWithMethod('PUT', '/v1.0/card/instances', {
492
+ outTrackId,
493
+ cardData: { cardParamMap },
494
+ });
495
+ }
496
+ /** StandardCard 模板结构(与钉钉官方 Go 打字机示例完全一致:text=标题,markdown=内容) */
497
+ function buildStandardCardData(cardData) {
498
+ const title = String(cardData.title ?? 'AI');
499
+ const content = String(cardData.content ?? cardData.displayText ?? '').trim() || '...';
500
+ const schema = {
501
+ config: { autoLayout: true, enableForward: true },
502
+ header: {
503
+ title: { type: 'text', text: title },
504
+ logo: '@lALPDfJ6V_FPDmvNAfTNAfQ',
505
+ },
506
+ contents: [
507
+ { type: 'text', text: title, id: 'text_1693929551595' },
508
+ { type: 'divider', id: 'divider_1693929551595' },
509
+ { type: 'markdown', text: content, id: 'markdown_1693929674245' },
510
+ ],
511
+ };
512
+ return JSON.stringify(schema);
513
+ }
514
+ /** 互动卡片普通版:发送(用于 prepare 失败时的 fallback 流式) */
515
+ export async function sendRobotInteractiveCard(target, cardBizId, cardData) {
516
+ const { chatId, robotCode, conversationType } = target;
517
+ if (!robotCode) {
518
+ throw new Error('DingTalk robotCode required for interactive card');
519
+ }
520
+ const cardDataStr = buildStandardCardData(cardData);
521
+ const isSingle = isSingleConversation(conversationType);
522
+ const body = {
523
+ cardTemplateId: 'StandardCard',
524
+ cardBizId,
525
+ outTrackId: cardBizId,
526
+ robotCode,
527
+ cardData: cardDataStr,
528
+ };
529
+ if (isSingle && target.senderStaffId) {
530
+ body.singleChatReceiver = JSON.stringify({ userid: target.senderStaffId });
531
+ }
532
+ else {
533
+ body.openConversationId = chatId;
534
+ }
535
+ log.debug(`DingTalk sendRobotInteractiveCard: isSingle=${isSingle}, robotCode=${robotCode?.slice(0, 8)}..., chatIdLen=${chatId?.length}`);
536
+ try {
537
+ await callOpenApiWithMethod('POST', '/v1.0/im/v1.0/robot/interactiveCards/send', body);
538
+ }
539
+ catch (err) {
540
+ const msg = err instanceof Error ? err.message : String(err);
541
+ if (msg.includes('param.error') || msg.includes('参数无效')) {
542
+ log.warn('DingTalk robot interactive card param.error - request body (no secrets):', JSON.stringify({ ...body, robotCode: body.robotCode ? '[REDACTED]' : undefined }, null, 2));
543
+ }
544
+ throw err;
545
+ }
546
+ }
547
+ /** 互动卡片普通版:更新(单条消息流式更新) */
548
+ export async function updateRobotInteractiveCard(cardBizId, cardData) {
549
+ const cardDataStr = buildStandardCardData(cardData);
550
+ const body = { cardBizId, cardData: cardDataStr };
551
+ await callOpenApiWithMethod('PUT', '/v1.0/im/robots/interactiveCards', body);
552
+ }
553
+ async function callOpenApiWithMethod(method, path, body) {
554
+ const accessToken = await getClient().getAccessToken();
555
+ const res = await fetch(`${DINGTALK_OPENAPI_BASE}${path}`, {
556
+ method,
557
+ headers: {
558
+ 'content-type': 'application/json',
559
+ 'x-acs-dingtalk-access-token': String(accessToken),
560
+ },
561
+ body: JSON.stringify(body),
562
+ signal: AbortSignal.timeout(30000),
563
+ });
564
+ const text = await res.text();
565
+ if (!res.ok) {
566
+ throw new Error(`DingTalk OpenAPI failed: ${res.status} ${text}`);
567
+ }
568
+ let parsed;
569
+ try {
570
+ parsed = JSON.parse(text);
571
+ }
572
+ catch {
573
+ return text;
574
+ }
575
+ const errorCode = parsed.errorcode ?? parsed.errcode;
576
+ const success = parsed.success;
577
+ if (errorCode === 0 ||
578
+ errorCode === '0' ||
579
+ success === true ||
580
+ (errorCode === undefined && success === undefined)) {
581
+ return parsed;
582
+ }
583
+ const errorMessage = typeof parsed.errmsg === 'string'
584
+ ? parsed.errmsg
585
+ : typeof parsed.errormsg === 'string'
586
+ ? parsed.errormsg
587
+ : typeof parsed.message === 'string'
588
+ ? parsed.message
589
+ : text;
590
+ throw new Error(`DingTalk OpenAPI business error: ${String(errorCode)} ${errorMessage}`);
591
+ }
@@ -1,6 +1,6 @@
1
1
  import { AccessControl } from '../access/access-control.js';
2
2
  import { RequestQueue } from '../queue/request-queue.js';
3
- import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, startTypingLoop, sendPermissionCard, sendModeCard, sendDirectorySelection, } from './message-sender.js';
3
+ import { configureDingTalkMessageSender, sendThinkingMessage, updateMessage, sendFinalMessages, sendErrorMessage, sendTextReply, startTypingLoop, sendPermissionCard, sendModeCard, sendDirectorySelection, } from './message-sender.js';
4
4
  import { ackMessage, registerSessionWebhook } from './client.js';
5
5
  import { registerPermissionSender } from '../hook/permission-server.js';
6
6
  import { CommandHandler } from '../commands/handler.js';
@@ -22,6 +22,16 @@ function parseRobotMessage(data) {
22
22
  }
23
23
  }
24
24
  export function setupDingTalkHandlers(config, sessionManager) {
25
+ configureDingTalkMessageSender({
26
+ cardTemplateId: config.dingtalkCardTemplateId,
27
+ robotCodeFallback: config.dingtalkClientId,
28
+ });
29
+ if (config.dingtalkCardTemplateId) {
30
+ log.info('DingTalk AI card streaming enabled');
31
+ }
32
+ else {
33
+ log.info('DingTalk AI card streaming disabled: no cardTemplateId configured');
34
+ }
25
35
  const accessControl = new AccessControl(config.dingtalkAllowedUserIds);
26
36
  const requestQueue = new RequestQueue();
27
37
  const runningTasks = new Map();
@@ -34,7 +44,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
34
44
  getRunningTasksSize: () => runningTasks.size,
35
45
  });
36
46
  registerPermissionSender('dingtalk', { sendTextReply, sendPermissionCard });
37
- async function handleAIRequest(userId, chatId, prompt, workDir, convId, _threadCtx, replyToMessageId) {
47
+ async function handleAIRequest(userId, chatId, prompt, workDir, convId, _threadCtx, replyToMessageId, dingtalkTarget) {
38
48
  log.info(`[AI_REQUEST] userId=${userId}, chatId=${chatId}, promptLength=${prompt.length}`);
39
49
  const toolAdapter = getAdapter(config.aiCommand);
40
50
  if (!toolAdapter) {
@@ -46,7 +56,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
46
56
  : undefined;
47
57
  log.info(`[AI_REQUEST] Running ${config.aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
48
58
  const toolId = config.aiCommand;
49
- const msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId);
59
+ const msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId, dingtalkTarget);
50
60
  const stopTyping = startTypingLoop(chatId);
51
61
  const taskKey = `${userId}:${msgId}`;
52
62
  await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'dingtalk', taskKey }, prompt, toolAdapter, {
@@ -58,7 +68,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
58
68
  await sendFinalMessages(chatId, msgId, content, note ?? '', toolId);
59
69
  },
60
70
  sendError: async (error) => {
61
- await sendTextReply(chatId, `错误:${error}`);
71
+ await sendErrorMessage(chatId, msgId, error, toolId);
62
72
  },
63
73
  extraCleanup: () => {
64
74
  stopTyping();
@@ -103,7 +113,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
103
113
  chatId,
104
114
  userId,
105
115
  conversationType: robotMessage.conversationType,
106
- robotCode: robotMessage.robotCode,
116
+ robotCode: robotMessage.robotCode || config.dingtalkClientId,
107
117
  });
108
118
  setChatUser(chatId, userId, 'dingtalk');
109
119
  try {
@@ -118,8 +128,15 @@ export function setupDingTalkHandlers(config, sessionManager) {
118
128
  }
119
129
  const workDir = sessionManager.getWorkDir(userId);
120
130
  const convId = sessionManager.getConvId(userId);
131
+ const dingtalkTarget = {
132
+ chatId,
133
+ conversationType: robotMessage.conversationType,
134
+ senderStaffId: robotMessage.senderStaffId,
135
+ senderId: robotMessage.senderId,
136
+ robotCode: robotMessage.robotCode || config.dingtalkClientId,
137
+ };
121
138
  const enqueueResult = requestQueue.enqueue(userId, convId, text, async (prompt) => {
122
- await handleAIRequest(userId, chatId, prompt, workDir, convId);
139
+ await handleAIRequest(userId, chatId, prompt, workDir, convId, undefined, undefined, dingtalkTarget);
123
140
  });
124
141
  if (enqueueResult === 'rejected') {
125
142
  await sendTextReply(chatId, '请求队列已满,请稍后再试。');
@@ -1,12 +1,20 @@
1
+ import type { DingTalkStreamingTarget } from './client.js';
1
2
  import type { ThreadContext } from '../shared/types.js';
2
3
  import type { DingTalkActiveTarget } from '../shared/active-chats.js';
3
4
  export type MessageStatus = 'thinking' | 'streaming' | 'done' | 'error';
4
- export declare function sendThinkingMessage(chatId: string, _replyToMessageId?: string, toolId?: string): Promise<string>;
5
+ interface SenderSettings {
6
+ cardTemplateId?: string;
7
+ robotCodeFallback?: string;
8
+ }
9
+ export declare function configureDingTalkMessageSender(settings: SenderSettings): void;
10
+ export declare function sendThinkingMessage(chatId: string, _replyToMessageId?: string, toolId?: string, target?: DingTalkStreamingTarget): Promise<string>;
5
11
  export declare function updateMessage(chatId: string, messageId: string, content: string, status: MessageStatus, note?: string, toolId?: string): Promise<void>;
6
12
  export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string, note: string, toolId?: string): Promise<void>;
13
+ export declare function sendErrorMessage(chatId: string, messageId: string, error: string, toolId?: string): Promise<void>;
7
14
  export declare function sendTextReply(chatId: string, text: string, _threadCtx?: ThreadContext | string): Promise<void>;
8
15
  export declare function sendProactiveTextReply(target: string | DingTalkActiveTarget, text: string): Promise<void>;
9
16
  export declare function sendPermissionCard(chatId: string, requestId: string, toolName: string, toolInput: string): Promise<void>;
10
17
  export declare function sendModeCard(chatId: string, _userId: string, currentMode: string): Promise<void>;
11
18
  export declare function sendDirectorySelection(chatId: string, currentDir: string, userId: string): Promise<void>;
12
19
  export declare function startTypingLoop(_chatId: string): () => void;
20
+ export {};
@@ -1,10 +1,10 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { basename } from 'node:path';
3
- import { sendText, sendProactiveText } from './client.js';
3
+ import { sendText, sendProactiveText, prepareStreamingCard, updateStreamingCard, finishStreamingCard, createAndDeliverCard, updateCardInstance, sendRobotInteractiveCard, updateRobotInteractiveCard, } from './client.js';
4
4
  import { createLogger } from '../logger.js';
5
5
  import { splitLongContent, getAIToolDisplayName } from '../shared/utils.js';
6
6
  import { listDirectories, buildDirectoryKeyboard } from '../commands/handler.js';
7
- import { MAX_WEWORK_MESSAGE_LENGTH } from '../constants.js';
7
+ import { MAX_DINGTALK_MESSAGE_LENGTH } from '../constants.js';
8
8
  const log = createLogger('DingTalkSender');
9
9
  const STATUS_ICONS = {
10
10
  thinking: '🔵',
@@ -12,9 +12,26 @@ const STATUS_ICONS = {
12
12
  done: '✅',
13
13
  error: '❌',
14
14
  };
15
+ const FLOW_STATUS = {
16
+ thinking: 1,
17
+ streaming: 2,
18
+ done: 3,
19
+ error: 5,
20
+ };
21
+ let senderSettings = {};
22
+ const streamStates = new Map();
15
23
  function generateMessageId() {
16
24
  return `${Date.now()}-${randomBytes(6).toString('hex')}`;
17
25
  }
26
+ export function configureDingTalkMessageSender(settings) {
27
+ senderSettings = {
28
+ cardTemplateId: settings.cardTemplateId?.trim(),
29
+ robotCodeFallback: settings.robotCodeFallback?.trim(),
30
+ };
31
+ }
32
+ function getCardTemplateId() {
33
+ return senderSettings.cardTemplateId?.trim() || undefined;
34
+ }
18
35
  function formatMessage(content, status, note, toolId = 'claude') {
19
36
  const icon = STATUS_ICONS[status];
20
37
  const toolName = getAIToolDisplayName(toolId);
@@ -30,6 +47,61 @@ function formatMessage(content, status, note, toolId = 'claude') {
30
47
  text += `\n\n─────────\n${note}`;
31
48
  return text;
32
49
  }
50
+ function getToolTitle(toolId, status) {
51
+ const toolName = getAIToolDisplayName(toolId);
52
+ if (status === 'done')
53
+ return toolName;
54
+ if (status === 'thinking')
55
+ return `${toolName} - 思考中`;
56
+ if (status === 'streaming')
57
+ return `${toolName} - 执行中`;
58
+ return `${toolName} - 错误`;
59
+ }
60
+ /**
61
+ * 适配钉钉官方「搜索结果卡片」模板变量结构
62
+ * 变量:lastMessage, content, resources, users, flowStatus
63
+ */
64
+ function buildCardData(content, status, note, toolId = 'claude') {
65
+ const toolName = getAIToolDisplayName(toolId);
66
+ const safeContent = content.trim() || (status === 'thinking' ? '正在思考,请稍候...' : status === 'error' ? '执行失败' : '...');
67
+ const safeNote = note?.trim() || '';
68
+ // lastMessage: 卡片摘要,用于会话列表预览
69
+ const lastMessage = safeContent.length > 50 ? `${safeContent.slice(0, 47)}...` : safeContent || getToolTitle(toolId, status);
70
+ // resources: 对象数组,note 作为来源列表(如 "1. xxx\n2. yyy" 按行解析)
71
+ const resources = [];
72
+ if (safeNote) {
73
+ for (const line of safeNote.split('\n')) {
74
+ const t = line.replace(/^\d+\.\s*/, '').trim();
75
+ if (t)
76
+ resources.push({ title: t });
77
+ }
78
+ if (resources.length === 0)
79
+ resources.push({ title: safeNote });
80
+ }
81
+ return {
82
+ lastMessage,
83
+ content: safeContent,
84
+ resources,
85
+ users: [],
86
+ flowStatus: FLOW_STATUS[status],
87
+ // 保留兼容字段
88
+ note: safeNote,
89
+ status,
90
+ toolName,
91
+ title: getToolTitle(toolId, status),
92
+ displayText: formatMessage(safeContent, status, safeNote, toolId),
93
+ };
94
+ }
95
+ async function tryFinishCard(conversationToken) {
96
+ if (!conversationToken)
97
+ return;
98
+ try {
99
+ await finishStreamingCard(conversationToken);
100
+ }
101
+ catch (err) {
102
+ log.warn('Failed to finish DingTalk streaming card:', err);
103
+ }
104
+ }
33
105
  async function sendTextWithRetry(chatId, text, retries = 1) {
34
106
  let lastError;
35
107
  for (let attempt = 0; attempt <= retries; attempt++) {
@@ -47,26 +119,165 @@ async function sendTextWithRetry(chatId, text, retries = 1) {
47
119
  }
48
120
  throw lastError;
49
121
  }
50
- export async function sendThinkingMessage(chatId, _replyToMessageId, toolId = 'claude') {
51
- // 钉钉 sessionWebhook 回复不支持像 Telegram/飞书那样稳定编辑原消息。
52
- // 为避免 “thinking -> streaming -> final” 连发多条,这里只生成一个本地 messageId,
53
- // 实际消息延迟到 sendFinalMessages/sendTextReply 阶段再发送。
54
- void chatId;
55
- void toolId;
56
- return generateMessageId();
122
+ export async function sendThinkingMessage(chatId, _replyToMessageId, toolId = 'claude', target) {
123
+ const messageId = generateMessageId();
124
+ const templateId = getCardTemplateId();
125
+ const robotCode = target?.robotCode || senderSettings.robotCodeFallback;
126
+ // 1. 优先尝试互动卡片普通版(机器人适用,无需 AI 助理权限)
127
+ if (robotCode) {
128
+ try {
129
+ const effectiveTarget = target
130
+ ? { ...target, robotCode }
131
+ : { chatId, robotCode };
132
+ const cardBizId = messageId;
133
+ await sendRobotInteractiveCard(effectiveTarget, cardBizId, buildCardData('', 'thinking', '请稍候', toolId));
134
+ streamStates.set(messageId, {
135
+ chatId,
136
+ mode: 'interactiveCard',
137
+ cardBizId,
138
+ toolId,
139
+ target,
140
+ });
141
+ return messageId;
142
+ }
143
+ catch (err) {
144
+ log.debug('DingTalk 互动卡片普通版失败,尝试其他方式:', err);
145
+ }
146
+ }
147
+ // 2. 尝试 AI 助理 prepare(需 AI 助理会话)或 createAndDeliver(高级版)
148
+ if (templateId) {
149
+ try {
150
+ const conversationToken = await prepareStreamingCard(target ?? chatId, templateId, buildCardData('', 'thinking', '请稍候', toolId));
151
+ streamStates.set(messageId, { chatId, mode: 'card', conversationToken, toolId, target });
152
+ return messageId;
153
+ }
154
+ catch (prepareErr) {
155
+ log.debug('DingTalk prepare failed, trying createAndDeliver:', prepareErr);
156
+ if (robotCode) {
157
+ try {
158
+ const effectiveTarget = target
159
+ ? { ...target, robotCode }
160
+ : { chatId, robotCode };
161
+ await createAndDeliverCard(effectiveTarget, templateId, messageId, buildCardData('', 'thinking', '请稍候', toolId));
162
+ streamStates.set(messageId, {
163
+ chatId,
164
+ mode: 'cardInstance',
165
+ outTrackId: messageId,
166
+ toolId,
167
+ target,
168
+ });
169
+ return messageId;
170
+ }
171
+ catch (cardErr) {
172
+ log.debug('DingTalk createAndDeliver failed:', cardErr);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ streamStates.set(messageId, { chatId, mode: 'text', toolId, target });
178
+ log.info('DingTalk 流式卡片不可用,将使用普通文本回复');
179
+ return messageId;
57
180
  }
58
181
  export async function updateMessage(chatId, messageId, content, status, note, toolId = 'claude') {
59
- // 钉钉第一版不发送中间更新,避免用户看到多条状态消息。
60
182
  void chatId;
61
- void messageId;
62
- void content;
63
- void status;
64
- void note;
65
- void toolId;
183
+ const state = streamStates.get(messageId);
184
+ if (!state)
185
+ return;
186
+ if (state.mode === 'card' && state.conversationToken) {
187
+ const templateId = getCardTemplateId();
188
+ if (!templateId)
189
+ return;
190
+ try {
191
+ await updateStreamingCard(state.conversationToken, templateId, buildCardData(content, status, note, toolId));
192
+ }
193
+ catch (err) {
194
+ log.warn('Failed to update DingTalk streaming card:', err);
195
+ }
196
+ return;
197
+ }
198
+ if (state.mode === 'cardInstance' && state.outTrackId) {
199
+ try {
200
+ await updateCardInstance(state.outTrackId, buildCardData(content, status, note, toolId));
201
+ }
202
+ catch (err) {
203
+ log.warn('Failed to update DingTalk card instance:', err);
204
+ }
205
+ return;
206
+ }
207
+ if (state.mode === 'interactiveCard' && state.cardBizId) {
208
+ try {
209
+ await updateRobotInteractiveCard(state.cardBizId, buildCardData(content, status, note, toolId));
210
+ }
211
+ catch (err) {
212
+ log.warn('Failed to update DingTalk interactive card:', err);
213
+ }
214
+ return;
215
+ }
66
216
  }
67
217
  export async function sendFinalMessages(chatId, messageId, fullContent, note, toolId = 'claude') {
68
- void messageId;
69
- const parts = splitLongContent(fullContent, MAX_WEWORK_MESSAGE_LENGTH);
218
+ const parts = splitLongContent(fullContent, MAX_DINGTALK_MESSAGE_LENGTH);
219
+ const templateId = getCardTemplateId();
220
+ const state = streamStates.get(messageId);
221
+ if (templateId && state?.mode === 'card' && state.conversationToken) {
222
+ let updatedCard = false;
223
+ try {
224
+ const cardNote = parts.length > 1 ? `内容较长,后续将继续发送 (${1}/${parts.length})` : note;
225
+ await updateStreamingCard(state.conversationToken, templateId, buildCardData(parts[0], 'done', cardNote, toolId));
226
+ updatedCard = true;
227
+ try {
228
+ await finishStreamingCard(state.conversationToken);
229
+ }
230
+ catch (err) {
231
+ log.warn('Failed to finish DingTalk streaming card after final update:', err);
232
+ }
233
+ streamStates.delete(messageId);
234
+ for (let i = 1; i < parts.length; i++) {
235
+ const partNote = i === parts.length - 1 ? note : `继续输出 (${i + 1}/${parts.length})`;
236
+ await sendTextWithRetry(chatId, formatMessage(parts[i], 'done', partNote, toolId));
237
+ }
238
+ return;
239
+ }
240
+ catch (err) {
241
+ if (updatedCard) {
242
+ log.warn('Final DingTalk card update already succeeded; skip text fallback:', err);
243
+ streamStates.delete(messageId);
244
+ return;
245
+ }
246
+ log.warn('Failed to finalize DingTalk streaming card, falling back to text:', err);
247
+ await tryFinishCard(state.conversationToken);
248
+ }
249
+ }
250
+ if (templateId && state?.mode === 'cardInstance' && state.outTrackId) {
251
+ try {
252
+ const cardNote = parts.length > 1 ? `内容较长,后续将继续发送 (${1}/${parts.length})` : note;
253
+ await updateCardInstance(state.outTrackId, buildCardData(parts[0], 'done', cardNote, toolId));
254
+ streamStates.delete(messageId);
255
+ for (let i = 1; i < parts.length; i++) {
256
+ const partNote = i === parts.length - 1 ? note : `继续输出 (${i + 1}/${parts.length})`;
257
+ await sendTextWithRetry(chatId, formatMessage(parts[i], 'done', partNote, toolId));
258
+ }
259
+ return;
260
+ }
261
+ catch (err) {
262
+ log.warn('Failed to finalize DingTalk card instance, falling back to text:', err);
263
+ }
264
+ }
265
+ if (state?.mode === 'interactiveCard' && state.cardBizId) {
266
+ try {
267
+ const cardNote = parts.length > 1 ? `内容较长,后续将继续发送 (${1}/${parts.length})` : note;
268
+ await updateRobotInteractiveCard(state.cardBizId, buildCardData(parts[0], 'done', cardNote, toolId));
269
+ streamStates.delete(messageId);
270
+ for (let i = 1; i < parts.length; i++) {
271
+ const partNote = i === parts.length - 1 ? note : `继续输出 (${i + 1}/${parts.length})`;
272
+ await sendTextWithRetry(chatId, formatMessage(parts[i], 'done', partNote, toolId));
273
+ }
274
+ return;
275
+ }
276
+ catch (err) {
277
+ log.warn('Failed to finalize DingTalk interactive card, falling back to text:', err);
278
+ }
279
+ }
280
+ streamStates.delete(messageId);
70
281
  for (let i = 0; i < parts.length; i++) {
71
282
  const partNote = parts.length > 1
72
283
  ? `${i === parts.length - 1 ? note + '\n' : ''}(续 ${i + 1}/${parts.length})`.trim()
@@ -74,6 +285,56 @@ export async function sendFinalMessages(chatId, messageId, fullContent, note, to
74
285
  await sendTextWithRetry(chatId, formatMessage(parts[i], 'done', partNote, toolId));
75
286
  }
76
287
  }
288
+ export async function sendErrorMessage(chatId, messageId, error, toolId = 'claude') {
289
+ const templateId = getCardTemplateId();
290
+ const state = streamStates.get(messageId);
291
+ if (templateId && state?.mode === 'card' && state.conversationToken) {
292
+ let updatedCard = false;
293
+ try {
294
+ await updateStreamingCard(state.conversationToken, templateId, buildCardData(`错误:${error}`, 'error', '执行失败', toolId));
295
+ updatedCard = true;
296
+ try {
297
+ await finishStreamingCard(state.conversationToken);
298
+ }
299
+ catch (err) {
300
+ log.warn('Failed to finish DingTalk error card after update:', err);
301
+ }
302
+ streamStates.delete(messageId);
303
+ return;
304
+ }
305
+ catch (err) {
306
+ if (updatedCard) {
307
+ log.warn('DingTalk error card update already succeeded; skip text fallback:', err);
308
+ streamStates.delete(messageId);
309
+ return;
310
+ }
311
+ log.warn('Failed to send DingTalk error card, falling back to text:', err);
312
+ await tryFinishCard(state.conversationToken);
313
+ }
314
+ }
315
+ if (templateId && state?.mode === 'cardInstance' && state.outTrackId) {
316
+ try {
317
+ await updateCardInstance(state.outTrackId, buildCardData(`错误:${error}`, 'error', '执行失败', toolId));
318
+ streamStates.delete(messageId);
319
+ return;
320
+ }
321
+ catch (err) {
322
+ log.warn('Failed to update DingTalk error card instance, falling back to text:', err);
323
+ }
324
+ }
325
+ if (state?.mode === 'interactiveCard' && state.cardBizId) {
326
+ try {
327
+ await updateRobotInteractiveCard(state.cardBizId, buildCardData(`错误:${error}`, 'error', '执行失败', toolId));
328
+ streamStates.delete(messageId);
329
+ return;
330
+ }
331
+ catch (err) {
332
+ log.warn('Failed to update DingTalk error interactive card, falling back to text:', err);
333
+ }
334
+ }
335
+ streamStates.delete(messageId);
336
+ await sendTextWithRetry(chatId, formatMessage(`错误:${error}`, 'error', '执行失败', toolId));
337
+ }
77
338
  export async function sendTextReply(chatId, text, _threadCtx) {
78
339
  await sendTextWithRetry(chatId, text);
79
340
  log.info(`Text reply sent to DingTalk chat ${chatId}`);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,131 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const sendTextMock = vi.fn();
3
+ const sendProactiveTextMock = vi.fn();
4
+ const prepareStreamingCardMock = vi.fn();
5
+ const updateStreamingCardMock = vi.fn();
6
+ const finishStreamingCardMock = vi.fn();
7
+ vi.mock('./client.js', () => ({
8
+ sendText: sendTextMock,
9
+ sendProactiveText: sendProactiveTextMock,
10
+ prepareStreamingCard: prepareStreamingCardMock,
11
+ updateStreamingCard: updateStreamingCardMock,
12
+ finishStreamingCard: finishStreamingCardMock,
13
+ }));
14
+ describe('DingTalk message sender', () => {
15
+ beforeEach(() => {
16
+ vi.resetModules();
17
+ sendTextMock.mockReset();
18
+ sendProactiveTextMock.mockReset();
19
+ prepareStreamingCardMock.mockReset();
20
+ updateStreamingCardMock.mockReset();
21
+ finishStreamingCardMock.mockReset();
22
+ });
23
+ it('uses AI card streaming when template is configured', async () => {
24
+ prepareStreamingCardMock.mockResolvedValue('ctx-1');
25
+ const sender = await import('./message-sender.js');
26
+ sender.configureDingTalkMessageSender({ cardTemplateId: 'tpl-1' });
27
+ const messageId = await sender.sendThinkingMessage('cid-1', undefined, 'codex', {
28
+ chatId: 'cid-1',
29
+ conversationType: '0',
30
+ senderStaffId: 'staff-1',
31
+ senderId: 'sender-1',
32
+ });
33
+ await sender.updateMessage('cid-1', messageId, '处理中', 'streaming', '执行中', 'codex');
34
+ await sender.sendFinalMessages('cid-1', messageId, '最终结果', '耗时 1s', 'codex');
35
+ expect(prepareStreamingCardMock).toHaveBeenCalledTimes(1);
36
+ expect(prepareStreamingCardMock).toHaveBeenCalledWith({
37
+ chatId: 'cid-1',
38
+ conversationType: '0',
39
+ senderStaffId: 'staff-1',
40
+ senderId: 'sender-1',
41
+ }, 'tpl-1', expect.objectContaining({
42
+ status: 'thinking',
43
+ flowStatus: 1,
44
+ toolName: 'Codex',
45
+ }));
46
+ expect(updateStreamingCardMock).toHaveBeenCalledTimes(2);
47
+ expect(updateStreamingCardMock).toHaveBeenNthCalledWith(1, 'ctx-1', 'tpl-1', expect.objectContaining({
48
+ content: '处理中',
49
+ note: '执行中',
50
+ status: 'streaming',
51
+ flowStatus: 2,
52
+ }));
53
+ expect(updateStreamingCardMock).toHaveBeenNthCalledWith(2, 'ctx-1', 'tpl-1', expect.objectContaining({
54
+ content: '最终结果',
55
+ note: '耗时 1s',
56
+ status: 'done',
57
+ flowStatus: 3,
58
+ }));
59
+ expect(finishStreamingCardMock).toHaveBeenCalledWith('ctx-1');
60
+ expect(sendTextMock).not.toHaveBeenCalled();
61
+ });
62
+ it('falls back to plain text when no card template is configured', async () => {
63
+ const sender = await import('./message-sender.js');
64
+ sender.configureDingTalkMessageSender({ cardTemplateId: '' });
65
+ const messageId = await sender.sendThinkingMessage('cid-2', undefined, 'claude');
66
+ await sender.sendFinalMessages('cid-2', messageId, '普通文本结果', '完成', 'claude');
67
+ expect(prepareStreamingCardMock).not.toHaveBeenCalled();
68
+ expect(updateStreamingCardMock).not.toHaveBeenCalled();
69
+ expect(finishStreamingCardMock).not.toHaveBeenCalled();
70
+ expect(sendTextMock).toHaveBeenCalledTimes(1);
71
+ expect(sendTextMock.mock.calls[0][1]).toContain('普通文本结果');
72
+ });
73
+ it('falls back to plain text when prepare fails', async () => {
74
+ prepareStreamingCardMock.mockRejectedValue(new Error('prepare failed'));
75
+ const sender = await import('./message-sender.js');
76
+ sender.configureDingTalkMessageSender({ cardTemplateId: 'tpl-2' });
77
+ const messageId = await sender.sendThinkingMessage('cid-3', undefined, 'claude');
78
+ await sender.sendFinalMessages('cid-3', messageId, '降级结果', '完成', 'claude');
79
+ expect(prepareStreamingCardMock).toHaveBeenCalledTimes(1);
80
+ expect(updateStreamingCardMock).not.toHaveBeenCalled();
81
+ expect(finishStreamingCardMock).not.toHaveBeenCalled();
82
+ expect(sendTextMock).toHaveBeenCalledTimes(1);
83
+ expect(sendTextMock.mock.calls[0][1]).toContain('降级结果');
84
+ });
85
+ it('updates card to error state and finishes it on failure', async () => {
86
+ prepareStreamingCardMock.mockResolvedValue('ctx-error');
87
+ const sender = await import('./message-sender.js');
88
+ sender.configureDingTalkMessageSender({ cardTemplateId: 'tpl-error' });
89
+ const messageId = await sender.sendThinkingMessage('cid-4', undefined, 'cursor');
90
+ await sender.sendErrorMessage('cid-4', messageId, '爆了', 'cursor');
91
+ expect(updateStreamingCardMock).toHaveBeenCalledWith('ctx-error', 'tpl-error', expect.objectContaining({
92
+ content: '错误:爆了',
93
+ note: '执行失败',
94
+ status: 'error',
95
+ flowStatus: 5,
96
+ toolName: 'Cursor',
97
+ }));
98
+ expect(finishStreamingCardMock).toHaveBeenCalledWith('ctx-error');
99
+ });
100
+ it('does not duplicate plain text when final card update succeeds but finish fails', async () => {
101
+ prepareStreamingCardMock.mockResolvedValue('ctx-finish');
102
+ finishStreamingCardMock.mockRejectedValue(new Error('finish failed'));
103
+ const sender = await import('./message-sender.js');
104
+ sender.configureDingTalkMessageSender({ cardTemplateId: 'tpl-finish' });
105
+ const messageId = await sender.sendThinkingMessage('cid-5', undefined, 'claude');
106
+ await sender.sendFinalMessages('cid-5', messageId, '最终结果', '完成', 'claude');
107
+ expect(updateStreamingCardMock).toHaveBeenCalledWith('ctx-finish', 'tpl-finish', expect.objectContaining({
108
+ content: '最终结果',
109
+ status: 'done',
110
+ }));
111
+ expect(finishStreamingCardMock).toHaveBeenCalledWith('ctx-finish');
112
+ expect(sendTextMock).not.toHaveBeenCalled();
113
+ });
114
+ it('passes single chat metadata to prepare call', async () => {
115
+ prepareStreamingCardMock.mockResolvedValue('ctx-meta');
116
+ const sender = await import('./message-sender.js');
117
+ sender.configureDingTalkMessageSender({ cardTemplateId: 'tpl-meta' });
118
+ await sender.sendThinkingMessage('cid-meta', undefined, 'claude', {
119
+ chatId: 'cid-meta',
120
+ conversationType: '0',
121
+ senderStaffId: '015038621332843498',
122
+ senderId: '$:LWCP_v1:$abc',
123
+ });
124
+ expect(prepareStreamingCardMock).toHaveBeenCalledWith({
125
+ chatId: 'cid-meta',
126
+ conversationType: '0',
127
+ senderStaffId: '015038621332843498',
128
+ senderId: '$:LWCP_v1:$abc',
129
+ }, 'tpl-meta', expect.any(Object));
130
+ });
131
+ });
package/dist/setup.js CHANGED
@@ -102,6 +102,7 @@ function printManualInstructions(configPath) {
102
102
  "enabled": false,
103
103
  "clientId": "你的钉钉 Client ID(可选)",
104
104
  "clientSecret": "你的钉钉 Client Secret(可选)",
105
+ "cardTemplateId": "你的钉钉 AI 卡片模板 ID(可选,配置后启用单条流式)",
105
106
  "allowedUserIds": ["允许访问的钉钉用户 ID(可选)"]
106
107
  },
107
108
  "wechat": {
@@ -485,14 +486,22 @@ export async function runInteractiveSetup() {
485
486
  initial: existing?.platforms?.dingtalk?.clientSecret ?? "",
486
487
  validate: (v) => (v.trim() ? true : "Client Secret 不能为空"),
487
488
  },
489
+ {
490
+ type: "text",
491
+ name: "cardTemplateId",
492
+ message: "钉钉 AI 卡片模板 ID(可选,配置后启用单条流式)",
493
+ initial: existing?.platforms?.dingtalk?.cardTemplateId ?? "",
494
+ },
488
495
  ], { onCancel });
489
496
  const dtClientId = dingtalkResp.clientId?.trim() || existing?.platforms?.dingtalk?.clientId;
490
497
  const dtClientSecret = dingtalkResp.clientSecret?.trim() || existing?.platforms?.dingtalk?.clientSecret;
498
+ const dtCardTemplateId = dingtalkResp.cardTemplateId?.trim() || existing?.platforms?.dingtalk?.cardTemplateId;
491
499
  if (dtClientId && dtClientSecret) {
492
500
  config.platforms.dingtalk = {
493
501
  enabled: true,
494
502
  clientId: dtClientId,
495
503
  clientSecret: dtClientSecret,
504
+ cardTemplateId: dtCardTemplateId,
496
505
  };
497
506
  }
498
507
  else if (platform === "dingtalk") {
@@ -827,6 +836,7 @@ export async function runInteractiveSetup() {
827
836
  enabled: true,
828
837
  clientId: config.platforms.dingtalk?.clientId,
829
838
  clientSecret: config.platforms.dingtalk?.clientSecret,
839
+ cardTemplateId: config.platforms.dingtalk?.cardTemplateId,
830
840
  allowedUserIds: dingtalkIds,
831
841
  };
832
842
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.5.1-beta.1",
3
+ "version": "1.5.2-beta.0",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, Cursor)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",