@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 +19 -0
- package/package.json +6 -2
- package/src/channel.ts +143 -9
- package/src/config-schema.ts +15 -13
- package/src/connection-manager.ts +24 -0
- package/src/onboarding.ts +66 -67
- package/src/types.ts +49 -28
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.
|
|
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 =>
|
|
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(
|
|
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
|
|
1602
|
+
const rawMediaPath = mediaPath || filePath || mediaUrl;
|
|
1483
1603
|
|
|
1484
1604
|
getLogger()?.debug?.(
|
|
1485
|
-
`[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl},
|
|
1605
|
+
`[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl}, rawMediaPath=${rawMediaPath}`
|
|
1486
1606
|
);
|
|
1487
1607
|
|
|
1488
|
-
if (!
|
|
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(
|
|
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?.(
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
|
2
|
-
import { DEFAULT_ACCOUNT_ID, normalizeAccountId, formatDocsLink } from
|
|
3
|
-
import type { DingTalkConfig, DingTalkChannelConfig } from
|
|
4
|
-
import { listDingTalkAccountIds, resolveDingTalkAccount } from
|
|
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 =
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
`Docs: ${formatDocsLink(
|
|
74
|
-
].join(
|
|
75
|
-
|
|
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:
|
|
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 ?
|
|
160
|
-
selectionHint: 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:
|
|
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:
|
|
184
|
-
placeholder:
|
|
182
|
+
message: 'Client ID (AppKey)',
|
|
183
|
+
placeholder: 'dingxxxxxxxx',
|
|
185
184
|
initialValue: resolved.clientId ?? undefined,
|
|
186
|
-
validate: (value) => (String(value ??
|
|
185
|
+
validate: (value) => (String(value ?? '').trim() ? undefined : 'Required'),
|
|
187
186
|
});
|
|
188
187
|
|
|
189
188
|
const clientSecret = await prompter.text({
|
|
190
|
-
message:
|
|
191
|
-
placeholder:
|
|
189
|
+
message: 'Client Secret (AppSecret)',
|
|
190
|
+
placeholder: 'xxx-xxx-xxx-xxx',
|
|
192
191
|
initialValue: resolved.clientSecret ?? undefined,
|
|
193
|
-
validate: (value) => (String(value ??
|
|
192
|
+
validate: (value) => (String(value ?? '').trim() ? undefined : 'Required'),
|
|
194
193
|
});
|
|
195
194
|
|
|
196
195
|
const wantsFullConfig = await prompter.confirm({
|
|
197
|
-
message:
|
|
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:
|
|
210
|
-
placeholder:
|
|
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:
|
|
219
|
-
placeholder:
|
|
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:
|
|
228
|
-
placeholder:
|
|
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:
|
|
236
|
-
initialValue: resolved.messageType ===
|
|
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:
|
|
240
|
+
let messageType: 'markdown' | 'card' = 'markdown';
|
|
242
241
|
|
|
243
242
|
if (wantsCardMode) {
|
|
244
243
|
await prompter.note(
|
|
245
244
|
[
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
].join(
|
|
253
|
-
|
|
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:
|
|
260
|
-
placeholder:
|
|
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:
|
|
269
|
-
placeholder:
|
|
270
|
-
initialValue: resolved.cardTemplateKey ??
|
|
271
|
-
})
|
|
272
|
-
).trim() ||
|
|
267
|
+
message: 'Card Template Key (content field name)',
|
|
268
|
+
placeholder: 'msgContent',
|
|
269
|
+
initialValue: resolved.cardTemplateKey ?? 'msgContent',
|
|
270
|
+
})
|
|
271
|
+
).trim() || 'msgContent';
|
|
273
272
|
|
|
274
|
-
messageType =
|
|
273
|
+
messageType = 'card';
|
|
275
274
|
}
|
|
276
275
|
|
|
277
276
|
const dmPolicyValue = await prompter.select({
|
|
278
|
-
message:
|
|
277
|
+
message: 'Direct message policy',
|
|
279
278
|
options: [
|
|
280
|
-
{ label:
|
|
281
|
-
{ label:
|
|
279
|
+
{ label: 'Open - anyone can DM', value: 'open' },
|
|
280
|
+
{ label: 'Allowlist - only allowed users', value: 'allowlist' },
|
|
282
281
|
],
|
|
283
|
-
initialValue: resolved.dmPolicy ??
|
|
282
|
+
initialValue: resolved.dmPolicy ?? 'open',
|
|
284
283
|
});
|
|
285
284
|
|
|
286
285
|
let allowFrom: string[] | undefined;
|
|
287
|
-
if (dmPolicyValue ===
|
|
286
|
+
if (dmPolicyValue === 'allowlist') {
|
|
288
287
|
const entry = await prompter.text({
|
|
289
|
-
message:
|
|
290
|
-
placeholder:
|
|
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:
|
|
296
|
+
message: 'Group message policy',
|
|
298
297
|
options: [
|
|
299
|
-
{ label:
|
|
300
|
-
{ label:
|
|
298
|
+
{ label: 'Open - any group can use bot', value: 'open' },
|
|
299
|
+
{ label: 'Allowlist - only allowed groups', value: 'allowlist' },
|
|
301
300
|
],
|
|
302
|
-
initialValue: resolved.groupPolicy ??
|
|
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
|
|
315
|
-
groupPolicy: groupPolicyValue as
|
|
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
|
|
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?:
|
|
32
|
-
groupPolicy?:
|
|
31
|
+
dmPolicy?: 'open' | 'pairing' | 'allowlist';
|
|
32
|
+
groupPolicy?: 'open' | 'allowlist';
|
|
33
33
|
allowFrom?: string[];
|
|
34
34
|
showThinking?: boolean;
|
|
35
35
|
debug?: boolean;
|
|
36
|
-
messageType?:
|
|
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?:
|
|
60
|
-
groupPolicy?:
|
|
59
|
+
dmPolicy?: 'open' | 'pairing' | 'allowlist';
|
|
60
|
+
groupPolicy?: 'open' | 'allowlist';
|
|
61
61
|
allowFrom?: string[];
|
|
62
62
|
showThinking?: boolean;
|
|
63
63
|
debug?: boolean;
|
|
64
|
-
messageType?:
|
|
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?:
|
|
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?:
|
|
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:
|
|
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:
|
|
381
|
-
INPUTING:
|
|
382
|
-
FINISHED:
|
|
383
|
-
FAILED:
|
|
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 =
|
|
422
|
-
CONNECTING =
|
|
423
|
-
CONNECTED =
|
|
424
|
-
DISCONNECTING =
|
|
425
|
-
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 =
|
|
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
|
};
|