@soimy/dingtalk 2.6.5 → 2.7.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
@@ -308,6 +308,7 @@ openclaw gateway restart
308
308
  - 通过 `cardTemplateId` 指定模板
309
309
  - 通过 `cardTemplateKey` 指定内容字段
310
310
  - **适用于 AI 对话场景**
311
+ - 支持在卡片中实时显示 AI 思考过程(推理流)和工具执行结果
311
312
 
312
313
  **AI Card API 特性:**
313
314
  当配置 `messageType: 'card'` 时:
@@ -317,6 +318,24 @@ openclaw gateway restart
317
318
  3. 自动状态管理(PROCESSING → INPUTING → FINISHED)
318
319
  4. 更稳定的流式体验,无需手动节流
319
320
 
321
+ ### AI 思考过程与工具执行显示(AI Card 模式)
322
+
323
+ 当 `messageType` 为 `card` 时,插件可以在卡片中实时展示 AI 的推理过程(🤔 思考中)和工具调用结果(🛠️ 工具执行)。这两项功能通过**对话级命令**控制,无需修改配置文件:
324
+
325
+ | 功能 | 对话命令 | 说明 |
326
+ | ----------------- | --------------------- | ---------------------------------- |
327
+ | 显示 AI 推理流 | `/reasoning stream` | 开启后,AI 思考内容实时更新到卡片 |
328
+ | 显示工具执行结果 | `/verbose on` | 开启后,工具调用结果实时更新到卡片 |
329
+ | 关闭 AI 推理流 | `/reasoning off` | 关闭推理流显示 |
330
+ | 关闭工具执行显示 | `/verbose off` | 关闭工具执行结果显示 |
331
+
332
+ **显示格式:**
333
+
334
+ - 思考内容以 `🤔 **思考中**` 为标题,正文以 `>` 引用块展示,最多显示前 500 个字符
335
+ - 工具结果以 `🛠️ **工具执行**` 为标题,正文以 `>` 引用块展示,最多显示前 500 个字符
336
+
337
+ > **注意:** 推理流和工具执行均会产生额外的卡片流式更新 API 调用,在 AI 推理步骤较多时可能显著增加 API 消耗,建议按需开启。
338
+
320
339
  **配置示例:**
321
340
 
322
341
  ```json5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "2.6.5",
3
+ "version": "2.7.0",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -31,6 +31,10 @@
31
31
  "url": "git+https://github.com/soimy/openclaw-channel-dingtalk.git"
32
32
  },
33
33
  "homepage": "https://github.com/soimy/openclaw-channel-dingtalk",
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "access": "public"
37
+ },
34
38
  "dependencies": {
35
39
  "axios": "^1.6.0",
36
40
  "dingtalk-stream": "^2.1.4",
@@ -76,4 +80,4 @@
76
80
  "defaultChoice": "npm"
77
81
  }
78
82
  }
79
- }
83
+ }
package/src/channel.ts CHANGED
@@ -65,6 +65,9 @@ const CARD_CACHE_TTL = 60 * 60 * 1000; // 1 hour
65
65
  // DingTalk API base URL
66
66
  const DINGTALK_API = 'https://api.dingtalk.com';
67
67
 
68
+ // Thinking and tool usage message truncate length
69
+ const THINKING_TRUNCATE_LENGTH = 500;
70
+
68
71
  // ============ Message Deduplication ============
69
72
  // Prevents duplicate message processing when DingTalk retries delivery
70
73
  // Uses pure in-memory storage with short TTL and lazy cleanup during processing
@@ -637,9 +640,72 @@ async function downloadMedia(
637
640
  function extractMessageContent(data: DingTalkInboundMessage): MessageContent {
638
641
  const msgtype = data.msgtype || 'text';
639
642
 
643
+ // Helper function to format quoted content from DingTalk's reply message structure
644
+ const formatQuotedContent = (): string => {
645
+ const textField = data.text as any;
646
+
647
+ // Case 1: isReplyMsg=true WITH repliedMsg content (desktop client)
648
+ if (textField?.isReplyMsg && textField?.repliedMsg) {
649
+ const repliedMsg = textField.repliedMsg;
650
+ const content = repliedMsg?.content;
651
+
652
+ // Try plain text format first
653
+ if (content?.text) {
654
+ const quoteText = content.text.trim();
655
+ if (quoteText) {
656
+ return `[引用消息: "${quoteText}"]\n\n`;
657
+ }
658
+ }
659
+
660
+ // Handle richText format
661
+ if (content?.richText && Array.isArray(content.richText)) {
662
+ const textParts: string[] = [];
663
+ for (const part of content.richText) {
664
+ if (part.msgType === 'text' && part.content) {
665
+ textParts.push(part.content);
666
+ } else if (part.msgType === 'emoji' || part.type === 'emoji') {
667
+ textParts.push(part.content || '[表情]');
668
+ } else if (part.msgType === 'picture' || part.type === 'picture') {
669
+ textParts.push('[图片]');
670
+ } else if (part.msgType === 'at' || part.type === 'at') {
671
+ textParts.push(`@${part.content || part.atName || '某人'}`);
672
+ } else if (part.text) {
673
+ textParts.push(part.text);
674
+ }
675
+ }
676
+ const quoteText = textParts.join('').trim();
677
+ if (quoteText) {
678
+ return `[引用消息: "${quoteText}"]\n\n`;
679
+ }
680
+ }
681
+ }
682
+
683
+ // Case 2: isReplyMsg=true WITHOUT repliedMsg (rich media quote, mobile or desktop - only has originalMsgId)
684
+ if (textField?.isReplyMsg && !textField?.repliedMsg && data.originalMsgId) {
685
+ return `[这是一条引用消息,原消息ID: ${data.originalMsgId}]\n\n`;
686
+ }
687
+
688
+ // Fallback: Check for quoteMessage field (legacy format)
689
+ if (data.quoteMessage) {
690
+ const quoteText = data.quoteMessage.text?.content?.trim() || '';
691
+ if (quoteText) {
692
+ return `[引用消息: "${quoteText}"]\n\n`;
693
+ }
694
+ }
695
+
696
+ // Fallback: Check for quoteContent in content field
697
+ if (data.content?.quoteContent) {
698
+ return `[引用消息: "${data.content.quoteContent}"]\n\n`;
699
+ }
700
+
701
+ return '';
702
+ };
703
+
704
+ const quotedPrefix = formatQuotedContent();
705
+
640
706
  // Logic for different message types
641
707
  if (msgtype === 'text') {
642
- return { text: data.text?.content?.trim() || '', messageType: 'text' };
708
+ return { text: quotedPrefix + (data.text?.content?.trim() || ''), messageType: 'text' };
643
709
  }
644
710
 
645
711
  // Improved richText parsing: join all text/at components and extract first picture
@@ -657,7 +723,7 @@ function extractMessageContent(data: DingTalkInboundMessage): MessageContent {
657
723
  }
658
724
  }
659
725
  return {
660
- text: text.trim() || (pictureDownloadCode ? '<media:image>' : '[富文本消息]'),
726
+ text: quotedPrefix + (text.trim() || (pictureDownloadCode ? '<media:image>' : '[富文本消息]')),
661
727
  mediaPath: pictureDownloadCode,
662
728
  mediaType: pictureDownloadCode ? 'image' : undefined,
663
729
  messageType: 'richText',
@@ -852,6 +918,29 @@ async function createAICard(
852
918
  }
853
919
  }
854
920
 
921
+ /**
922
+ * Format thinking/tool content for display in AI Card
923
+ * Truncates to configured length and adds "> " prefix to each line
924
+ */
925
+ function formatContentForCard(content: string, type: 'thinking' | 'tool'): string {
926
+ if (!content) return '';
927
+
928
+ // truncate to configured length, add ellipsis if truncated
929
+ const truncated = content.slice(0, THINKING_TRUNCATE_LENGTH) + (content.length > THINKING_TRUNCATE_LENGTH ? '…' : '');
930
+
931
+ // split into lines, then escape leading/trailing underscore per line, then prefix with ">"
932
+ const quotedLines = truncated
933
+ .split('\n')
934
+ .map((line) => line.replace(/^_(?=[^ ])/, '*').replace(/(?<=[^ ])_(?=$)/, '*'))
935
+ .map((line) => `> ${line}`)
936
+ .join('\n');
937
+
938
+ const emoji = type === 'thinking' ? '🤔' : '🛠️';
939
+ const label = type === 'thinking' ? '思考中' : '工具执行';
940
+
941
+ return `${emoji} **${label}**\n${quotedLines}`;
942
+ }
943
+
855
944
  /**
856
945
  * Stream update AI Card content using the new DingTalk API
857
946
  * Always use isFull=true to fully replace the Markdown content
@@ -1292,11 +1381,24 @@ async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promi
1292
1381
  cfg,
1293
1382
  dispatcherOptions: {
1294
1383
  responsePrefix: '',
1295
- deliver: async (payload: any) => {
1384
+ deliver: async (payload: any, info?: { kind: string }) => {
1296
1385
  try {
1297
1386
  const textToSend = payload.markdown || payload.text;
1298
1387
  if (!textToSend) return;
1299
1388
 
1389
+ // Handle tool results separately for AI Card streaming
1390
+ //
1391
+ // Note: use /verbose on in conversation to get tool execution info
1392
+ //
1393
+ if (useCardMode && currentAICard && info?.kind === 'tool') {
1394
+ log?.info?.(`[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`);
1395
+ const toolText = formatContentForCard(textToSend, 'tool');
1396
+ if (toolText) {
1397
+ await streamAICard(currentAICard, toolText, false, log);
1398
+ return; // Don't send via sendMessage for tool results in card mode
1399
+ }
1400
+ }
1401
+
1300
1402
  lastCardContent = textToSend;
1301
1403
  await sendMessage(dingtalkConfig, to, textToSend, {
1302
1404
  sessionWebhook,
@@ -1310,6 +1412,21 @@ async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promi
1310
1412
  }
1311
1413
  },
1312
1414
  },
1415
+ replyOptions: {
1416
+ // Handle reasoning stream updates to update the AI Card content in real-time
1417
+ // Note: use /reasoning stream in conversation to get reasoning stream updates
1418
+ //
1419
+ onReasoningStream: async (payload: any) => {
1420
+ if (!useCardMode || !currentAICard) { return; }
1421
+ const thinkingText = formatContentForCard(payload.text, 'thinking');
1422
+ if (!thinkingText) return;
1423
+ try {
1424
+ await streamAICard(currentAICard, thinkingText, false, log);
1425
+ } catch (err: any) {
1426
+ log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
1427
+ }
1428
+ },
1429
+ },
1313
1430
  });
1314
1431
 
1315
1432
  // Finalize AI card
@@ -1401,7 +1518,8 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1401
1518
  };
1402
1519
  },
1403
1520
  defaultAccountId: (): string => 'default',
1404
- isConfigured: (account: ResolvedAccount): boolean => Boolean(account.config?.clientId && account.config?.clientSecret),
1521
+ isConfigured: (account: ResolvedAccount): boolean =>
1522
+ Boolean(account.config?.clientId && account.config?.clientSecret),
1405
1523
  describeAccount: (account: ResolvedAccount) => ({
1406
1524
  accountId: account.accountId,
1407
1525
  name: account.config?.name || 'DingTalk',
@@ -1462,7 +1580,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1462
1580
  }
1463
1581
  throw new Error(typeof result.error === 'string' ? result.error : JSON.stringify(result.error));
1464
1582
  } catch (err: any) {
1465
- throw new Error(typeof err?.response?.data === 'string' ? err.response.data : err?.message || 'sendText failed');
1583
+ throw new Error(
1584
+ typeof err?.response?.data === 'string' ? err.response.data : err?.message || 'sendText failed'
1585
+ );
1466
1586
  }
1467
1587
  },
1468
1588
  sendMedia: async ({
@@ -1479,13 +1599,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1479
1599
  if (!config.clientId) throw new Error('DingTalk not configured');
1480
1600
 
1481
1601
  // Support mediaPath, filePath, and mediaUrl parameter names
1482
- const actualMediaPath = mediaPath || filePath || mediaUrl;
1602
+ const rawMediaPath = mediaPath || filePath || mediaUrl;
1483
1603
 
1484
1604
  getLogger()?.debug?.(
1485
- `[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl}, actualMediaPath=${actualMediaPath}`
1605
+ `[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl}, rawMediaPath=${rawMediaPath}`
1486
1606
  );
1487
1607
 
1488
- if (!actualMediaPath) {
1608
+ if (!rawMediaPath) {
1489
1609
  throw new Error(
1490
1610
  `mediaPath, filePath, or mediaUrl is required. Received: ${JSON.stringify({
1491
1611
  to,
@@ -1496,6 +1616,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1496
1616
  );
1497
1617
  }
1498
1618
 
1619
+ // Resolve user path to expand ~ and relative paths
1620
+ const actualMediaPath = resolveUserPath(rawMediaPath);
1621
+
1622
+ getLogger()?.debug?.(
1623
+ `[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`
1624
+ );
1625
+
1499
1626
  try {
1500
1627
  // Detect media type from file extension if not provided
1501
1628
  const mediaType = providedMediaType || detectMediaTypeFromExtension(actualMediaPath);
@@ -1518,7 +1645,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1518
1645
  }
1519
1646
  throw new Error(typeof result.error === 'string' ? result.error : JSON.stringify(result.error));
1520
1647
  } catch (err: any) {
1521
- throw new Error(typeof err?.response?.data === 'string' ? err.response.data : err?.message || 'sendMedia failed');
1648
+ throw new Error(
1649
+ typeof err?.response?.data === 'string' ? err.response.data : err?.message || 'sendMedia failed'
1650
+ );
1522
1651
  }
1523
1652
  },
1524
1653
  },
@@ -1674,6 +1803,11 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
1674
1803
  lastError: null,
1675
1804
  });
1676
1805
  ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
1806
+
1807
+ // Keep startAccount alive until the connection manager is explicitly stopped.
1808
+ // The Gateway treats the Promise resolution as "channel finished" and would
1809
+ // trigger auto-restart if we returned here immediately after connecting.
1810
+ await connectionManager.waitForStop();
1677
1811
  } else {
1678
1812
  // Startup was cancelled or connection is not established; do not overwrite stopped snapshot.
1679
1813
  ctx.log?.info?.(
@@ -1,10 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- /**
4
- * DingTalk configuration schema using Zod
5
- * Mirrors the structure needed for proper control-ui rendering
6
- */
7
- export const DingTalkConfigSchema: z.ZodTypeAny = z.object({
3
+ const DingTalkAccountConfigSchema = z.object({
8
4
  /** Account name (optional display name) */
9
5
  name: z.string().optional(),
10
6
 
@@ -66,14 +62,6 @@ export const DingTalkConfigSchema: z.ZodTypeAny = z.object({
66
62
  )
67
63
  .optional(),
68
64
 
69
- /** Multi-account configuration */
70
- accounts: z
71
- .record(
72
- z.string(),
73
- z.lazy(() => DingTalkConfigSchema)
74
- )
75
- .optional(),
76
-
77
65
  /** Connection robustness configuration */
78
66
 
79
67
  /** Maximum number of connection attempts before giving up (default: 10) */
@@ -89,4 +77,18 @@ export const DingTalkConfigSchema: z.ZodTypeAny = z.object({
89
77
  reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
90
78
  });
91
79
 
80
+ /**
81
+ * DingTalk configuration schema using Zod
82
+ * Mirrors the structure needed for proper control-ui rendering
83
+ */
84
+ export const DingTalkConfigSchema: z.ZodTypeAny = DingTalkAccountConfigSchema.extend({
85
+ /** Multi-account configuration */
86
+ accounts: z
87
+ .record(
88
+ z.string(),
89
+ DingTalkAccountConfigSchema.optional()
90
+ )
91
+ .optional(),
92
+ });
93
+
92
94
  export type DingTalkConfig = z.infer<typeof DingTalkConfigSchema>;
@@ -37,6 +37,9 @@ export class ConnectionManager {
37
37
  private sleepTimeout?: NodeJS.Timeout;
38
38
  private sleepResolve?: () => void;
39
39
 
40
+ // Stop signal for waitForStop()
41
+ private stopPromiseResolvers: Array<() => void> = [];
42
+
40
43
  // Client reference
41
44
  private client: DWClient;
42
45
 
@@ -366,6 +369,27 @@ export class ConnectionManager {
366
369
 
367
370
  this.state = ConnectionStateEnum.DISCONNECTED;
368
371
  this.log?.info?.(`[${this.accountId}] Connection manager stopped`);
372
+
373
+ // Resolve all pending waitForStop() promises
374
+ for (const resolve of this.stopPromiseResolvers) {
375
+ resolve();
376
+ }
377
+ this.stopPromiseResolvers = [];
378
+ }
379
+
380
+ /**
381
+ * Returns a Promise that resolves when the connection manager is stopped.
382
+ * Useful for keeping a caller alive (e.g. startAccount) until the channel
383
+ * is explicitly stopped via stop() or an abort signal handler that calls stop().
384
+ * Safe to call concurrently; all pending callers are resolved when stop() is called.
385
+ */
386
+ public waitForStop(): Promise<void> {
387
+ if (this.stopped) {
388
+ return Promise.resolve();
389
+ }
390
+ return new Promise<void>((resolve) => {
391
+ this.stopPromiseResolvers.push(resolve);
392
+ });
369
393
  }
370
394
 
371
395
  /**
package/src/onboarding.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { OpenClawConfig, ChannelOnboardingAdapter, WizardPrompter } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId, formatDocsLink } from "openclaw/plugin-sdk";
3
- import type { DingTalkConfig, DingTalkChannelConfig } from "./types.js";
4
- import { listDingTalkAccountIds, resolveDingTalkAccount } from "./types.js";
1
+ import type { OpenClawConfig, ChannelOnboardingAdapter, WizardPrompter } from 'openclaw/plugin-sdk';
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId, formatDocsLink } from 'openclaw/plugin-sdk';
3
+ import type { DingTalkConfig, DingTalkChannelConfig } from './types.js';
4
+ import { listDingTalkAccountIds, resolveDingTalkAccount } from './types.js';
5
5
 
6
- const channel = "dingtalk" as const;
6
+ const channel = 'dingtalk' as const;
7
7
 
8
8
  function isConfigured(account: DingTalkConfig): boolean {
9
9
  return Boolean(account.clientId && account.clientSecret);
@@ -64,15 +64,15 @@ async function promptDingTalkAccountId(options: {
64
64
  async function noteDingTalkHelp(prompter: WizardPrompter): Promise<void> {
65
65
  await prompter.note(
66
66
  [
67
- "You need DingTalk application credentials.",
68
- "1. Visit https://open-dev.dingtalk.com/",
69
- "2. Create an enterprise internal application",
67
+ 'You need DingTalk application credentials.',
68
+ '1. Visit https://open-dev.dingtalk.com/',
69
+ '2. Create an enterprise internal application',
70
70
  "3. Enable 'Robot' capability",
71
71
  "4. Configure message receiving mode as 'Stream mode'",
72
- "5. Copy Client ID (AppKey) and Client Secret (AppSecret)",
73
- `Docs: ${formatDocsLink("/channels/dingtalk", "channels/dingtalk")}`,
74
- ].join("\n"),
75
- "DingTalk setup",
72
+ '5. Copy Client ID (AppKey) and Client Secret (AppSecret)',
73
+ `Docs: ${formatDocsLink('/channels/dingtalk', 'channels/dingtalk')}`,
74
+ ].join('\n'),
75
+ 'DingTalk setup'
76
76
  );
77
77
  }
78
78
 
@@ -86,7 +86,7 @@ function applyAccountConfig(params: {
86
86
 
87
87
  const namedConfig = applyAccountNameToChannelSection({
88
88
  cfg,
89
- channelKey: "dingtalk",
89
+ channelKey: 'dingtalk',
90
90
  accountId,
91
91
  name: input.name,
92
92
  });
@@ -103,7 +103,7 @@ function applyAccountConfig(params: {
103
103
  ...(input.allowFrom && input.allowFrom.length > 0 ? { allowFrom: input.allowFrom } : {}),
104
104
  ...(input.messageType ? { messageType: input.messageType } : {}),
105
105
  ...(input.cardTemplateId ? { cardTemplateId: input.cardTemplateId } : {}),
106
- ...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {}),
106
+ ...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {})
107
107
  };
108
108
 
109
109
  if (useDefault) {
@@ -121,8 +121,7 @@ function applyAccountConfig(params: {
121
121
  }
122
122
 
123
123
  const accounts = (base as { accounts?: Record<string, unknown> }).accounts ?? {};
124
- const existingAccount =
125
- (base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[accountId] ?? {};
124
+ const existingAccount = (base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[accountId] ?? {};
126
125
 
127
126
  return {
128
127
  ...namedConfig,
@@ -156,8 +155,8 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
156
155
  return Promise.resolve({
157
156
  channel,
158
157
  configured,
159
- statusLines: [`DingTalk: ${configured ? "configured" : "needs setup"}`],
160
- selectionHint: configured ? "configured" : "钉钉企业机器人",
158
+ statusLines: [`DingTalk: ${configured ? 'configured' : 'needs setup'}`],
159
+ selectionHint: configured ? 'configured' : '钉钉企业机器人',
161
160
  quickstartScore: configured ? 1 : 4,
162
161
  });
163
162
  },
@@ -169,7 +168,7 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
169
168
  accountId = await promptDingTalkAccountId({
170
169
  cfg,
171
170
  prompter,
172
- label: "DingTalk",
171
+ label: 'DingTalk',
173
172
  currentId: accountId,
174
173
  listAccountIds: listDingTalkAccountIds,
175
174
  defaultAccountId: DEFAULT_ACCOUNT_ID,
@@ -180,21 +179,21 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
180
179
  await noteDingTalkHelp(prompter);
181
180
 
182
181
  const clientId = await prompter.text({
183
- message: "Client ID (AppKey)",
184
- placeholder: "dingxxxxxxxx",
182
+ message: 'Client ID (AppKey)',
183
+ placeholder: 'dingxxxxxxxx',
185
184
  initialValue: resolved.clientId ?? undefined,
186
- validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
185
+ validate: (value) => (String(value ?? '').trim() ? undefined : 'Required'),
187
186
  });
188
187
 
189
188
  const clientSecret = await prompter.text({
190
- message: "Client Secret (AppSecret)",
191
- placeholder: "xxx-xxx-xxx-xxx",
189
+ message: 'Client Secret (AppSecret)',
190
+ placeholder: 'xxx-xxx-xxx-xxx',
192
191
  initialValue: resolved.clientSecret ?? undefined,
193
- validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
192
+ validate: (value) => (String(value ?? '').trim() ? undefined : 'Required'),
194
193
  });
195
194
 
196
195
  const wantsFullConfig = await prompter.confirm({
197
- message: "Configure robot code, corp ID, and agent ID? (recommended for full features)",
196
+ message: 'Configure robot code, corp ID, and agent ID? (recommended for full features)',
198
197
  initialValue: false,
199
198
  });
200
199
 
@@ -206,100 +205,100 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
206
205
  robotCode =
207
206
  String(
208
207
  await prompter.text({
209
- message: "Robot Code",
210
- placeholder: "dingxxxxxxxx",
208
+ message: 'Robot Code',
209
+ placeholder: 'dingxxxxxxxx',
211
210
  initialValue: resolved.robotCode ?? undefined,
212
- }),
211
+ })
213
212
  ).trim() || undefined;
214
213
 
215
214
  corpId =
216
215
  String(
217
216
  await prompter.text({
218
- message: "Corp ID",
219
- placeholder: "dingxxxxxxxx",
217
+ message: 'Corp ID',
218
+ placeholder: 'dingxxxxxxxx',
220
219
  initialValue: resolved.corpId ?? undefined,
221
- }),
220
+ })
222
221
  ).trim() || undefined;
223
222
 
224
223
  agentId =
225
224
  String(
226
225
  await prompter.text({
227
- message: "Agent ID",
228
- placeholder: "123456789",
226
+ message: 'Agent ID',
227
+ placeholder: '123456789',
229
228
  initialValue: resolved.agentId ? String(resolved.agentId) : undefined,
230
- }),
229
+ })
231
230
  ).trim() || undefined;
232
231
  }
233
232
 
234
233
  const wantsCardMode = await prompter.confirm({
235
- message: "Enable AI interactive card mode? (for streaming AI responses)",
236
- initialValue: resolved.messageType === "card",
234
+ message: 'Enable AI interactive card mode? (for streaming AI responses)',
235
+ initialValue: resolved.messageType === 'card',
237
236
  });
238
237
 
239
238
  let cardTemplateId: string | undefined;
240
239
  let cardTemplateKey: string | undefined;
241
- let messageType: "markdown" | "card" = "markdown";
240
+ let messageType: 'markdown' | 'card' = 'markdown';
242
241
 
243
242
  if (wantsCardMode) {
244
243
  await prompter.note(
245
244
  [
246
- "Create an AI card template in DingTalk Developer Console:",
247
- "https://open-dev.dingtalk.com/fe/card",
245
+ 'Create an AI card template in DingTalk Developer Console:',
246
+ 'https://open-dev.dingtalk.com/fe/card',
248
247
  "1. Go to 'My Templates' > 'Create Template'",
249
248
  "2. Select 'AI Card' scenario",
250
- "3. Design your card and publish",
251
- "4. Copy the Template ID (e.g., xxx.schema)",
252
- ].join("\n"),
253
- "Card Template Setup",
249
+ '3. Design your card and publish',
250
+ '4. Copy the Template ID (e.g., xxx.schema)',
251
+ ].join('\n'),
252
+ 'Card Template Setup'
254
253
  );
255
254
 
256
255
  cardTemplateId =
257
256
  String(
258
257
  await prompter.text({
259
- message: "Card Template ID",
260
- placeholder: "xxxxx-xxxxx-xxxxx.schema",
258
+ message: 'Card Template ID',
259
+ placeholder: 'xxxxx-xxxxx-xxxxx.schema',
261
260
  initialValue: resolved.cardTemplateId ?? undefined,
262
- }),
261
+ })
263
262
  ).trim() || undefined;
264
263
 
265
264
  cardTemplateKey =
266
265
  String(
267
266
  await prompter.text({
268
- message: "Card Template Key (content field name)",
269
- placeholder: "msgContent",
270
- initialValue: resolved.cardTemplateKey ?? "msgContent",
271
- }),
272
- ).trim() || "msgContent";
267
+ message: 'Card Template Key (content field name)',
268
+ placeholder: 'msgContent',
269
+ initialValue: resolved.cardTemplateKey ?? 'msgContent',
270
+ })
271
+ ).trim() || 'msgContent';
273
272
 
274
- messageType = "card";
273
+ messageType = 'card';
275
274
  }
276
275
 
277
276
  const dmPolicyValue = await prompter.select({
278
- message: "Direct message policy",
277
+ message: 'Direct message policy',
279
278
  options: [
280
- { label: "Open - anyone can DM", value: "open" },
281
- { label: "Allowlist - only allowed users", value: "allowlist" },
279
+ { label: 'Open - anyone can DM', value: 'open' },
280
+ { label: 'Allowlist - only allowed users', value: 'allowlist' },
282
281
  ],
283
- initialValue: resolved.dmPolicy ?? "open",
282
+ initialValue: resolved.dmPolicy ?? 'open',
284
283
  });
285
284
 
286
285
  let allowFrom: string[] | undefined;
287
- if (dmPolicyValue === "allowlist") {
286
+ if (dmPolicyValue === 'allowlist') {
288
287
  const entry = await prompter.text({
289
- message: "Allowed user IDs (comma-separated)",
290
- placeholder: "user1, user2",
288
+ message: 'Allowed user IDs (comma-separated)',
289
+ placeholder: 'user1, user2',
291
290
  });
292
- const parsed = parseList(String(entry ?? ""));
291
+ const parsed = parseList(String(entry ?? ''));
293
292
  allowFrom = parsed.length > 0 ? parsed : undefined;
294
293
  }
295
294
 
296
295
  const groupPolicyValue = await prompter.select({
297
- message: "Group message policy",
296
+ message: 'Group message policy',
298
297
  options: [
299
- { label: "Open - any group can use bot", value: "open" },
300
- { label: "Allowlist - only allowed groups", value: "allowlist" },
298
+ { label: 'Open - any group can use bot', value: 'open' },
299
+ { label: 'Allowlist - only allowed groups', value: 'allowlist' },
301
300
  ],
302
- initialValue: resolved.groupPolicy ?? "open",
301
+ initialValue: resolved.groupPolicy ?? 'open',
303
302
  });
304
303
 
305
304
  const next = applyAccountConfig({
@@ -311,8 +310,8 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
311
310
  robotCode,
312
311
  corpId,
313
312
  agentId,
314
- dmPolicy: dmPolicyValue as "open" | "allowlist",
315
- groupPolicy: groupPolicyValue as "open" | "allowlist",
313
+ dmPolicy: dmPolicyValue as 'open' | 'allowlist',
314
+ groupPolicy: groupPolicyValue as 'open' | 'allowlist',
316
315
  allowFrom,
317
316
  messageType,
318
317
  cardTemplateId,
package/src/types.ts CHANGED
@@ -15,7 +15,7 @@ import type {
15
15
  ChannelAccountSnapshot as SDKChannelAccountSnapshot,
16
16
  ChannelGatewayContext as SDKChannelGatewayContext,
17
17
  ChannelPlugin as SDKChannelPlugin,
18
- } from "openclaw/plugin-sdk";
18
+ } from 'openclaw/plugin-sdk';
19
19
 
20
20
  /**
21
21
  * DingTalk channel configuration (extends base OpenClaw config)
@@ -28,12 +28,12 @@ export interface DingTalkConfig extends OpenClawConfig {
28
28
  agentId?: string;
29
29
  name?: string;
30
30
  enabled?: boolean;
31
- dmPolicy?: "open" | "pairing" | "allowlist";
32
- groupPolicy?: "open" | "allowlist";
31
+ dmPolicy?: 'open' | 'pairing' | 'allowlist';
32
+ groupPolicy?: 'open' | 'allowlist';
33
33
  allowFrom?: string[];
34
34
  showThinking?: boolean;
35
35
  debug?: boolean;
36
- messageType?: "markdown" | "card";
36
+ messageType?: 'markdown' | 'card';
37
37
  cardTemplateId?: string;
38
38
  cardTemplateKey?: string;
39
39
  groups?: Record<string, { systemPrompt?: string }>;
@@ -56,12 +56,12 @@ export interface DingTalkChannelConfig {
56
56
  corpId?: string;
57
57
  agentId?: string;
58
58
  name?: string;
59
- dmPolicy?: "open" | "pairing" | "allowlist";
60
- groupPolicy?: "open" | "allowlist";
59
+ dmPolicy?: 'open' | 'pairing' | 'allowlist';
60
+ groupPolicy?: 'open' | 'allowlist';
61
61
  allowFrom?: string[];
62
62
  showThinking?: boolean;
63
63
  debug?: boolean;
64
- messageType?: "markdown" | "card";
64
+ messageType?: 'markdown' | 'card';
65
65
  cardTemplateId?: string;
66
66
  cardTemplateKey?: string;
67
67
  groups?: Record<string, { systemPrompt?: string }>;
@@ -123,6 +123,19 @@ export interface DingTalkInboundMessage {
123
123
  createAt: number;
124
124
  text?: {
125
125
  content: string;
126
+ isReplyMsg?: boolean; // 是否是回复消息
127
+ repliedMsg?: { // 被回复的消息
128
+ content?: {
129
+ text?: string;
130
+ richText?: Array<{
131
+ msgType?: string;
132
+ type?: string;
133
+ content?: string;
134
+ code?: string;
135
+ atName?: string;
136
+ }>;
137
+ };
138
+ };
126
139
  };
127
140
  content?: {
128
141
  downloadCode?: string;
@@ -134,7 +147,18 @@ export interface DingTalkInboundMessage {
134
147
  atName?: string;
135
148
  downloadCode?: string; // For picture type in richText
136
149
  }>;
150
+ quoteContent?: string; // 替代引用格式
151
+ };
152
+ // Legacy 引用格式
153
+ quoteMessage?: {
154
+ msgId?: string;
155
+ msgtype?: string;
156
+ text?: { content: string; };
157
+ senderNick?: string;
158
+ senderId?: string;
137
159
  };
160
+ // 富媒体引用,仅有消息ID的情况(包括手机端和PC端)
161
+ originalMsgId?: string;
138
162
  conversationType: string;
139
163
  conversationId: string;
140
164
  conversationTitle?: string;
@@ -166,7 +190,7 @@ export interface SendMessageOptions {
166
190
  mediaPath?: string;
167
191
  filePath?: string;
168
192
  mediaUrl?: string;
169
- mediaType?: "image" | "voice" | "video" | "file";
193
+ mediaType?: 'image' | 'voice' | 'video' | 'file';
170
194
  }
171
195
 
172
196
  /**
@@ -238,7 +262,7 @@ export interface AxiosRequestConfig {
238
262
  method?: string;
239
263
  data?: any;
240
264
  headers?: Record<string, string>;
241
- responseType?: "arraybuffer" | "json" | "text";
265
+ responseType?: 'arraybuffer' | 'json' | 'text';
242
266
  }
243
267
 
244
268
  /**
@@ -367,7 +391,7 @@ export interface SendMediaParams {
367
391
  * DingTalk outbound handler configuration
368
392
  */
369
393
  export interface DingTalkOutboundHandler {
370
- deliveryMode: "direct" | "queued" | "batch";
394
+ deliveryMode: 'direct' | 'queued' | 'batch';
371
395
  resolveTarget: (params: ResolveTargetParams) => TargetResolutionResult;
372
396
  sendText: (params: SendTextParams) => Promise<{ ok: boolean; data?: any; error?: any }>;
373
397
  sendMedia?: (params: SendMediaParams) => Promise<{ ok: boolean; data?: any; error?: any }>;
@@ -377,10 +401,10 @@ export interface DingTalkOutboundHandler {
377
401
  * AI Card status constants
378
402
  */
379
403
  export const AICardStatus = {
380
- PROCESSING: "1",
381
- INPUTING: "2",
382
- FINISHED: "3",
383
- FAILED: "5",
404
+ PROCESSING: '1',
405
+ INPUTING: '2',
406
+ FINISHED: '3',
407
+ FAILED: '5',
384
408
  } as const;
385
409
 
386
410
  /**
@@ -418,11 +442,11 @@ export interface AICardStreamingRequest {
418
442
  * Connection state enum for lifecycle management
419
443
  */
420
444
  export enum ConnectionState {
421
- DISCONNECTED = "DISCONNECTED",
422
- CONNECTING = "CONNECTING",
423
- CONNECTED = "CONNECTED",
424
- DISCONNECTING = "DISCONNECTING",
425
- FAILED = "FAILED",
445
+ DISCONNECTED = 'DISCONNECTED',
446
+ CONNECTING = 'CONNECTING',
447
+ CONNECTED = 'CONNECTED',
448
+ DISCONNECTING = 'DISCONNECTING',
449
+ FAILED = 'FAILED',
426
450
  }
427
451
 
428
452
  /**
@@ -449,7 +473,7 @@ export interface ConnectionAttemptResult {
449
473
 
450
474
  // ============ Onboarding Helper Functions ============
451
475
 
452
- const DEFAULT_ACCOUNT_ID = "default";
476
+ const DEFAULT_ACCOUNT_ID = 'default';
453
477
 
454
478
  /**
455
479
  * List all DingTalk account IDs from config
@@ -484,18 +508,15 @@ export interface ResolvedDingTalkAccount extends DingTalkConfig {
484
508
  /**
485
509
  * Resolve a specific DingTalk account configuration
486
510
  */
487
- export function resolveDingTalkAccount(
488
- cfg: OpenClawConfig,
489
- accountId?: string | null,
490
- ): ResolvedDingTalkAccount {
511
+ export function resolveDingTalkAccount(cfg: OpenClawConfig, accountId?: string | null): ResolvedDingTalkAccount {
491
512
  const id = accountId || DEFAULT_ACCOUNT_ID;
492
513
  const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
493
514
 
494
515
  // If default account, return top-level config
495
516
  if (id === DEFAULT_ACCOUNT_ID) {
496
517
  const config: DingTalkConfig = {
497
- clientId: dingtalk?.clientId ?? "",
498
- clientSecret: dingtalk?.clientSecret ?? "",
518
+ clientId: dingtalk?.clientId ?? '',
519
+ clientSecret: dingtalk?.clientSecret ?? '',
499
520
  robotCode: dingtalk?.robotCode,
500
521
  corpId: dingtalk?.corpId,
501
522
  agentId: dingtalk?.agentId,
@@ -535,8 +556,8 @@ export function resolveDingTalkAccount(
535
556
 
536
557
  // Account doesn't exist, return empty config
537
558
  return {
538
- clientId: "",
539
- clientSecret: "",
559
+ clientId: '',
560
+ clientSecret: '',
540
561
  accountId: id,
541
562
  configured: false,
542
563
  };