@wu529778790/open-im 1.5.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
@@ -185,18 +185,10 @@ Claude 默认使用 Agent SDK,不依赖本地 `claude` 可执行文件;通
185
185
  说明:钉钉当前采用“Stream Mode 收消息 + OpenAPI 发送消息”的混合模式。
186
186
 
187
187
  - 会话内普通文本回复默认走 `sessionWebhook`
188
- - 如果配置了 `cardTemplateId` / `DINGTALK_CARD_TEMPLATE_ID`,AI 回复会升级为单条 `ai_card` 流式更新(`prepare / update / finish`)
188
+ - 若配置了 `cardTemplateId`,会尝试 AI 助理 `prepare/update/finish` 流式卡片;失败则 fallback 为普通文本(自定义机器人/普通群场景下互动卡片 API 报 param.error,暂不支持单条流式更新)
189
189
  - 启动/关闭通知会发给最近一次已互动的钉钉会话;如果服务冷启动后还没有任何钉钉会话互动过,则没有可用目标可发
190
190
 
191
- 钉钉 AI 卡片模板需至少兼容以下字段,建议模板按这些 key 取值:
192
-
193
- - `title`
194
- - `content`
195
- - `note`
196
- - `toolName`
197
- - `status`
198
- - `flowStatus`
199
- - `displayText`
191
+ 钉钉 AI 卡片模板:已适配官方「搜索结果卡片」模板,使用变量 `lastMessage`、`content`、`resources`、`users`、`flowStatus`。若使用该模板,无需修改模板即可实现流式更新。
200
192
 
201
193
  ## IM 内命令
202
194
 
@@ -233,7 +225,7 @@ Claude 默认使用 Agent SDK,不依赖本地 `claude` 可执行文件;通
233
225
 
234
226
  **钉钉无法回复**:确认应用已启用机器人 Stream Mode,并检查 `DINGTALK_CLIENT_ID`、`DINGTALK_CLIENT_SECRET` 或 `platforms.dingtalk` 配置是否正确。
235
227
 
236
- **钉钉没有流式更新**:如果未配置 `DINGTALK_CARD_TEMPLATE_ID`(或 `platforms.dingtalk.cardTemplateId`),会自动退回普通文本回复;配置 AI 卡片模板后才会启用单条流式更新。
228
+ **钉钉没有流式更新**:prepare 失败时 fallback 为普通文本回复。自定义机器人/普通群场景下,AI 助理和互动卡片 API 均不可用,仅支持单条文本回复。
237
229
 
238
230
  **Cursor 报 `Authentication required`**:先执行 `agent login`,或在 `env` 中设置 `CURSOR_API_KEY`。
239
231
 
@@ -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,6 +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>;
11
- export declare function prepareStreamingCard(chatId: string, templateId: string, cardData: Record<string, unknown>): Promise<string>;
18
+ export declare function prepareStreamingCard(target: string | DingTalkStreamingTarget, templateId: string, cardData: Record<string, unknown>): Promise<string>;
12
19
  export declare function updateStreamingCard(conversationToken: string, templateId: string, cardData: Record<string, unknown>): Promise<void>;
13
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');
@@ -115,9 +117,72 @@ async function callOpenApi(path, body) {
115
117
  : text;
116
118
  throw new Error(`DingTalk OpenAPI business error: ${String(errorCode)} ${errorMessage}`);
117
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
+ }
118
152
  function normalizeConversationType(type) {
119
153
  return type?.trim().toLowerCase();
120
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
+ }
121
186
  function buildProactiveAttempts(target, content) {
122
187
  const robotCode = getRobotCode(target);
123
188
  const payload = buildTextPayload(content);
@@ -214,6 +279,7 @@ export function stopDingTalk() {
214
279
  }
215
280
  finally {
216
281
  sessionWebhookByChat.clear();
282
+ unionIdByUserId.clear();
217
283
  client = null;
218
284
  messageHandler = null;
219
285
  log.info('DingTalk client stopped');
@@ -236,19 +302,107 @@ export async function sendProactiveText(target, content) {
236
302
  }
237
303
  catch (err) {
238
304
  lastError = err;
239
- 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
+ }
240
312
  }
241
313
  }
242
314
  throw lastError instanceof Error
243
315
  ? lastError
244
316
  : new Error(`DingTalk proactive send failed for chat ${target.chatId}`);
245
317
  }
246
- export async function prepareStreamingCard(chatId, templateId, cardData) {
247
- const result = await callOpenApi('/v1.0/aiInteraction/prepare', {
248
- openConversationId: chatId,
249
- contentType: 'ai_card',
250
- content: buildAiCardContent(templateId, cardData),
251
- });
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
+ }
252
406
  const token = result.result?.conversationToken;
253
407
  if (typeof token !== 'string' || token.length === 0) {
254
408
  throw new Error(`DingTalk prepare did not return conversationToken: ${JSON.stringify(result)}`);
@@ -275,3 +429,163 @@ export async function finishStreamingCard(conversationToken) {
275
429
  throw new Error(`DingTalk finish returned success=false: ${JSON.stringify(result)}`);
276
430
  }
277
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
+ }
@@ -24,6 +24,7 @@ function parseRobotMessage(data) {
24
24
  export function setupDingTalkHandlers(config, sessionManager) {
25
25
  configureDingTalkMessageSender({
26
26
  cardTemplateId: config.dingtalkCardTemplateId,
27
+ robotCodeFallback: config.dingtalkClientId,
27
28
  });
28
29
  if (config.dingtalkCardTemplateId) {
29
30
  log.info('DingTalk AI card streaming enabled');
@@ -43,7 +44,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
43
44
  getRunningTasksSize: () => runningTasks.size,
44
45
  });
45
46
  registerPermissionSender('dingtalk', { sendTextReply, sendPermissionCard });
46
- async function handleAIRequest(userId, chatId, prompt, workDir, convId, _threadCtx, replyToMessageId) {
47
+ async function handleAIRequest(userId, chatId, prompt, workDir, convId, _threadCtx, replyToMessageId, dingtalkTarget) {
47
48
  log.info(`[AI_REQUEST] userId=${userId}, chatId=${chatId}, promptLength=${prompt.length}`);
48
49
  const toolAdapter = getAdapter(config.aiCommand);
49
50
  if (!toolAdapter) {
@@ -55,7 +56,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
55
56
  : undefined;
56
57
  log.info(`[AI_REQUEST] Running ${config.aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
57
58
  const toolId = config.aiCommand;
58
- const msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId);
59
+ const msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId, dingtalkTarget);
59
60
  const stopTyping = startTypingLoop(chatId);
60
61
  const taskKey = `${userId}:${msgId}`;
61
62
  await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'dingtalk', taskKey }, prompt, toolAdapter, {
@@ -112,7 +113,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
112
113
  chatId,
113
114
  userId,
114
115
  conversationType: robotMessage.conversationType,
115
- robotCode: robotMessage.robotCode,
116
+ robotCode: robotMessage.robotCode || config.dingtalkClientId,
116
117
  });
117
118
  setChatUser(chatId, userId, 'dingtalk');
118
119
  try {
@@ -127,8 +128,15 @@ export function setupDingTalkHandlers(config, sessionManager) {
127
128
  }
128
129
  const workDir = sessionManager.getWorkDir(userId);
129
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
+ };
130
138
  const enqueueResult = requestQueue.enqueue(userId, convId, text, async (prompt) => {
131
- await handleAIRequest(userId, chatId, prompt, workDir, convId);
139
+ await handleAIRequest(userId, chatId, prompt, workDir, convId, undefined, undefined, dingtalkTarget);
132
140
  });
133
141
  if (enqueueResult === 'rejected') {
134
142
  await sendTextReply(chatId, '请求队列已满,请稍后再试。');
@@ -1,11 +1,13 @@
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
5
  interface SenderSettings {
5
6
  cardTemplateId?: string;
7
+ robotCodeFallback?: string;
6
8
  }
7
9
  export declare function configureDingTalkMessageSender(settings: SenderSettings): void;
8
- export declare function sendThinkingMessage(chatId: string, _replyToMessageId?: string, toolId?: string): Promise<string>;
10
+ export declare function sendThinkingMessage(chatId: string, _replyToMessageId?: string, toolId?: string, target?: DingTalkStreamingTarget): Promise<string>;
9
11
  export declare function updateMessage(chatId: string, messageId: string, content: string, status: MessageStatus, note?: string, toolId?: string): Promise<void>;
10
12
  export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string, note: string, toolId?: string): Promise<void>;
11
13
  export declare function sendErrorMessage(chatId: string, messageId: string, error: string, toolId?: string): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { basename } from 'node:path';
3
- import { sendText, sendProactiveText, prepareStreamingCard, updateStreamingCard, finishStreamingCard, } 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';
@@ -26,6 +26,7 @@ function generateMessageId() {
26
26
  export function configureDingTalkMessageSender(settings) {
27
27
  senderSettings = {
28
28
  cardTemplateId: settings.cardTemplateId?.trim(),
29
+ robotCodeFallback: settings.robotCodeFallback?.trim(),
29
30
  };
30
31
  }
31
32
  function getCardTemplateId() {
@@ -56,20 +57,39 @@ function getToolTitle(toolId, status) {
56
57
  return `${toolName} - 执行中`;
57
58
  return `${toolName} - 错误`;
58
59
  }
60
+ /**
61
+ * 适配钉钉官方「搜索结果卡片」模板变量结构
62
+ * 变量:lastMessage, content, resources, users, flowStatus
63
+ */
59
64
  function buildCardData(content, status, note, toolId = 'claude') {
60
65
  const toolName = getAIToolDisplayName(toolId);
61
66
  const safeContent = content.trim() || (status === 'thinking' ? '正在思考,请稍候...' : status === 'error' ? '执行失败' : '...');
62
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
+ }
63
81
  return {
64
- title: getToolTitle(toolId, status),
82
+ lastMessage,
65
83
  content: safeContent,
84
+ resources,
85
+ users: [],
86
+ flowStatus: FLOW_STATUS[status],
87
+ // 保留兼容字段
66
88
  note: safeNote,
67
- toolName,
68
89
  status,
69
- flowStatus: FLOW_STATUS[status],
70
- icon: STATUS_ICONS[status],
90
+ toolName,
91
+ title: getToolTitle(toolId, status),
71
92
  displayText: formatMessage(safeContent, status, safeNote, toolId),
72
- updatedAt: new Date().toISOString(),
73
93
  };
74
94
  }
75
95
  async function tryFinishCard(conversationToken) {
@@ -99,33 +119,99 @@ async function sendTextWithRetry(chatId, text, retries = 1) {
99
119
  }
100
120
  throw lastError;
101
121
  }
102
- export async function sendThinkingMessage(chatId, _replyToMessageId, toolId = 'claude') {
122
+ export async function sendThinkingMessage(chatId, _replyToMessageId, toolId = 'claude', target) {
103
123
  const messageId = generateMessageId();
104
124
  const templateId = getCardTemplateId();
105
- if (!templateId)
106
- return messageId;
107
- try {
108
- const conversationToken = await prepareStreamingCard(chatId, templateId, buildCardData('', 'thinking', '请稍候', toolId));
109
- streamStates.set(messageId, { chatId, mode: 'card', conversationToken, toolId });
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
+ }
110
146
  }
111
- catch (err) {
112
- log.warn('Failed to prepare DingTalk streaming card, falling back to text:', err);
113
- streamStates.set(messageId, { chatId, mode: 'text', toolId });
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
+ }
114
176
  }
177
+ streamStates.set(messageId, { chatId, mode: 'text', toolId, target });
178
+ log.info('DingTalk 流式卡片不可用,将使用普通文本回复');
115
179
  return messageId;
116
180
  }
117
181
  export async function updateMessage(chatId, messageId, content, status, note, toolId = 'claude') {
118
182
  void chatId;
119
- const templateId = getCardTemplateId();
120
183
  const state = streamStates.get(messageId);
121
- if (!templateId || !state || state.mode !== 'card' || !state.conversationToken) {
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
+ }
122
196
  return;
123
197
  }
124
- try {
125
- await updateStreamingCard(state.conversationToken, templateId, buildCardData(content, status, note, toolId));
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;
126
206
  }
127
- catch (err) {
128
- log.warn('Failed to update DingTalk streaming card:', err);
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;
129
215
  }
130
216
  }
131
217
  export async function sendFinalMessages(chatId, messageId, fullContent, note, toolId = 'claude') {
@@ -161,6 +247,36 @@ export async function sendFinalMessages(chatId, messageId, fullContent, note, to
161
247
  await tryFinishCard(state.conversationToken);
162
248
  }
163
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
+ }
164
280
  streamStates.delete(messageId);
165
281
  for (let i = 0; i < parts.length; i++) {
166
282
  const partNote = parts.length > 1
@@ -196,6 +312,26 @@ export async function sendErrorMessage(chatId, messageId, error, toolId = 'claud
196
312
  await tryFinishCard(state.conversationToken);
197
313
  }
198
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
+ }
199
335
  streamStates.delete(messageId);
200
336
  await sendTextWithRetry(chatId, formatMessage(`错误:${error}`, 'error', '执行失败', toolId));
201
337
  }
@@ -24,11 +24,21 @@ describe('DingTalk message sender', () => {
24
24
  prepareStreamingCardMock.mockResolvedValue('ctx-1');
25
25
  const sender = await import('./message-sender.js');
26
26
  sender.configureDingTalkMessageSender({ cardTemplateId: 'tpl-1' });
27
- const messageId = await sender.sendThinkingMessage('cid-1', undefined, 'codex');
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
+ });
28
33
  await sender.updateMessage('cid-1', messageId, '处理中', 'streaming', '执行中', 'codex');
29
34
  await sender.sendFinalMessages('cid-1', messageId, '最终结果', '耗时 1s', 'codex');
30
35
  expect(prepareStreamingCardMock).toHaveBeenCalledTimes(1);
31
- expect(prepareStreamingCardMock).toHaveBeenCalledWith('cid-1', 'tpl-1', expect.objectContaining({
36
+ expect(prepareStreamingCardMock).toHaveBeenCalledWith({
37
+ chatId: 'cid-1',
38
+ conversationType: '0',
39
+ senderStaffId: 'staff-1',
40
+ senderId: 'sender-1',
41
+ }, 'tpl-1', expect.objectContaining({
32
42
  status: 'thinking',
33
43
  flowStatus: 1,
34
44
  toolName: 'Codex',
@@ -101,4 +111,21 @@ describe('DingTalk message sender', () => {
101
111
  expect(finishStreamingCardMock).toHaveBeenCalledWith('ctx-finish');
102
112
  expect(sendTextMock).not.toHaveBeenCalled();
103
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
+ });
104
131
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.5.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",