@soimy/dingtalk 3.3.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +141 -12
  2. package/index.ts +71 -66
  3. package/package.json +6 -5
  4. package/src/access-control.ts +65 -0
  5. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  8. package/src/ack-reaction-classifier.ts +17 -4
  9. package/src/ack-reaction-service.ts +66 -19
  10. package/src/attachment-text-extractor.ts +2 -1
  11. package/src/card-service.ts +145 -257
  12. package/src/channel.ts +106 -47
  13. package/src/config-schema.ts +28 -6
  14. package/src/config.ts +30 -6
  15. package/src/connection-manager.ts +16 -5
  16. package/src/inbound-handler.ts +694 -520
  17. package/src/media-utils.ts +99 -36
  18. package/src/message-context-store.ts +787 -0
  19. package/src/message-utils.ts +221 -42
  20. package/src/messaging/quoted-context.ts +269 -0
  21. package/src/messaging/quoted-ref.ts +97 -0
  22. package/src/onboarding.ts +381 -269
  23. package/src/reply-strategy-card.ts +225 -0
  24. package/src/reply-strategy-markdown.ts +55 -0
  25. package/src/reply-strategy-with-reaction.ts +190 -0
  26. package/src/reply-strategy.ts +72 -0
  27. package/src/runtime.ts +5 -7
  28. package/src/send-service.ts +164 -62
  29. package/src/targeting/agent-name-matcher.ts +148 -0
  30. package/src/targeting/agent-routing.ts +181 -0
  31. package/src/targeting/target-directory-adapter.ts +152 -0
  32. package/src/targeting/target-directory-store.ts +396 -0
  33. package/src/targeting/target-input.ts +62 -0
  34. package/src/types.ts +124 -21
  35. package/src/quote-journal.ts +0 -242
  36. package/src/quoted-msg-cache.ts +0 -226
package/src/types.ts CHANGED
@@ -10,22 +10,21 @@
10
10
  */
11
11
 
12
12
  import type {
13
+ ChannelPlugin as SDKChannelPlugin,
13
14
  OpenClawConfig,
14
- OpenClawPluginApi,
15
- ChannelLogSink as SDKChannelLogSink,
15
+ } from "openclaw/plugin-sdk/core";
16
+ import type {
16
17
  ChannelAccountSnapshot as SDKChannelAccountSnapshot,
17
18
  ChannelGatewayContext as SDKChannelGatewayContext,
18
- ChannelPlugin as SDKChannelPlugin,
19
- } from "openclaw/plugin-sdk";
19
+ ChannelLogSink as SDKChannelLogSink,
20
+ } from "openclaw/plugin-sdk/channel-runtime";
21
+ import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
20
22
  import { mergeAccountWithDefaults } from "./config";
21
23
 
22
- export interface DingtalkPluginModule {
23
- id: string;
24
- name: string;
25
- description?: string;
26
- configSchema?: unknown;
27
- register?: (api: OpenClawPluginApi) => void | Promise<void>;
28
- }
24
+ export type AckReactionMode = "off" | "emoji" | "kaomoji";
25
+ // Accept arbitrary strings for backward compatibility; the recommended
26
+ // explicit modes remain: "off" | "emoji" | "kaomoji".
27
+ export type AckReactionConfigValue = string;
29
28
 
30
29
  /**
31
30
  * DingTalk channel configuration (extends base OpenClaw config)
@@ -39,16 +38,18 @@ export interface DingTalkConfig extends OpenClawConfig {
39
38
  name?: string;
40
39
  enabled?: boolean;
41
40
  dmPolicy?: "open" | "pairing" | "allowlist";
42
- groupPolicy?: "open" | "allowlist";
41
+ groupPolicy?: "open" | "allowlist" | "disabled";
43
42
  allowFrom?: string[];
43
+ groupAllowFrom?: string[];
44
+ displayNameResolution?: "disabled" | "all";
44
45
  mediaUrlAllowlist?: string[];
45
46
  journalTTLDays?: number;
46
- ackReaction?: string;
47
+ ackReaction?: AckReactionConfigValue;
47
48
  debug?: boolean;
48
49
  messageType?: "markdown" | "card";
49
50
  cardTemplateId?: string;
50
51
  cardTemplateKey?: string;
51
- groups?: Record<string, { systemPrompt?: string }>;
52
+ groups?: Record<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
52
53
  accounts?: Record<string, DingTalkConfig>;
53
54
  // Connection robustness configuration
54
55
  maxConnectionAttempts?: number;
@@ -87,6 +88,10 @@ export interface DingTalkConfig extends OpenClawConfig {
87
88
  feedbackLearningAutoApply?: boolean;
88
89
  /** @deprecated Use learningNoteTtlMs */
89
90
  feedbackLearningNoteTtlMs?: number;
91
+ /** Whether to convert markdown tables to plain text for better rendering on some clients (default: true) */
92
+ convertMarkdownTables?: boolean;
93
+ /** @mention the sender after card finalization in group chats; value is the message text */
94
+ cardAtSender?: string;
90
95
  }
91
96
 
92
97
  /**
@@ -101,16 +106,18 @@ export interface DingTalkChannelConfig {
101
106
  agentId?: string;
102
107
  name?: string;
103
108
  dmPolicy?: "open" | "pairing" | "allowlist";
104
- groupPolicy?: "open" | "allowlist";
109
+ groupPolicy?: "open" | "allowlist" | "disabled";
105
110
  allowFrom?: string[];
111
+ groupAllowFrom?: string[];
112
+ displayNameResolution?: "disabled" | "all";
106
113
  mediaUrlAllowlist?: string[];
107
114
  journalTTLDays?: number;
108
- ackReaction?: string;
115
+ ackReaction?: AckReactionConfigValue;
109
116
  debug?: boolean;
110
117
  messageType?: "markdown" | "card";
111
118
  cardTemplateId?: string;
112
119
  cardTemplateKey?: string;
113
- groups?: Record<string, { systemPrompt?: string }>;
120
+ groups?: Record<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
114
121
  accounts?: Record<string, DingTalkConfig>;
115
122
  maxConnectionAttempts?: number;
116
123
  initialReconnectDelay?: number;
@@ -148,6 +155,10 @@ export interface DingTalkChannelConfig {
148
155
  feedbackLearningAutoApply?: boolean;
149
156
  /** @deprecated Use learningNoteTtlMs */
150
157
  feedbackLearningNoteTtlMs?: number;
158
+ /** Whether to convert markdown tables to plain text for better rendering on some clients (default: true) */
159
+ convertMarkdownTables?: boolean;
160
+ /** @mention the sender after card finalization in group chats; value is the message text */
161
+ cardAtSender?: string;
151
162
  }
152
163
 
153
164
  /**
@@ -207,6 +218,14 @@ export interface DingTalkInboundMessage {
207
218
  msgId: string;
208
219
  msgtype: string;
209
220
  createAt: number;
221
+ /**
222
+ * @ 提及的用户列表(消息顶层,与 text 同级)
223
+ * 包含通过 @picker 选中的所有真实钉钉用户和机器人
224
+ * 格式: [{ dingtalkId: "$:LWCP_v1:$xxx" }]
225
+ */
226
+ atUsers?: Array<{
227
+ dingtalkId: string;
228
+ }>;
210
229
  text?: {
211
230
  content: string;
212
231
  isReplyMsg?: boolean; // 是否是回复消息
@@ -218,6 +237,7 @@ export interface DingTalkInboundMessage {
218
237
  content?: {
219
238
  text?: string;
220
239
  downloadCode?: string;
240
+ fileName?: string;
221
241
  biz_custom_action_url?: string;
222
242
  richText?: Array<{
223
243
  msgType?: string;
@@ -242,6 +262,7 @@ export interface DingTalkInboundMessage {
242
262
  type: string;
243
263
  text?: string;
244
264
  atName?: string;
265
+ atUserId?: string;
245
266
  downloadCode?: string;
246
267
  }>;
247
268
  quoteContent?: string;
@@ -267,24 +288,58 @@ export interface DingTalkInboundMessage {
267
288
  sessionWebhook: string;
268
289
  }
269
290
 
291
+ export type QuotedRefKey = "msgId" | "processQueryKey" | "messageId" | "outTrackId" | "cardInstanceId";
292
+
293
+ export type AttachmentTextSource = "text" | "html" | "pdf" | "docx";
294
+
295
+ export interface QuotedRef {
296
+ targetDirection: "inbound" | "outbound";
297
+ key?: QuotedRefKey;
298
+ value?: string;
299
+ fallbackCreatedAt?: number;
300
+ }
301
+
270
302
  /**
271
303
  * Quoted/reply message metadata extracted from repliedMsg.
272
304
  * Populated when isReplyMsg is true; downstream handlers use these fields
273
305
  * to download quoted media or look up cached card content.
274
306
  */
275
307
  export interface QuotedInfo {
276
- prefix: string;
277
308
  mediaDownloadCode?: string;
278
309
  mediaType?: string;
279
310
  isQuotedFile?: boolean;
280
311
  isQuotedCard?: boolean;
281
312
  isQuotedDocCard?: boolean;
282
- docSpaceId?: string;
283
- docFileId?: string;
284
313
  cardCreatedAt?: number;
285
314
  processQueryKey?: string;
286
315
  fileCreatedAt?: number;
287
316
  msgId?: string;
317
+ previewText?: string;
318
+ previewMessageType?: string;
319
+ previewFileName?: string;
320
+ previewSenderId?: string;
321
+ }
322
+
323
+ /**
324
+ * @ 提及信息
325
+ */
326
+ export interface AtMention {
327
+ /** @ 显示的名字(去除 @ 前缀) */
328
+ name: string;
329
+ /** 钉钉用户 ID(如果是 @ 真人) */
330
+ userId?: string;
331
+ }
332
+
333
+ /**
334
+ * Agent 名字匹配结果
335
+ */
336
+ export interface AgentNameMatch {
337
+ /** 匹配到的 agent ID */
338
+ agentId: string;
339
+ /** 匹配来源:'name' | 'id' */
340
+ matchSource: "name" | "id";
341
+ /** 匹配到的名字 */
342
+ matchedName: string;
288
343
  }
289
344
 
290
345
  /**
@@ -300,6 +355,16 @@ export interface MessageContent {
300
355
  docSpaceId?: string;
301
356
  docFileId?: string;
302
357
  quoted?: QuotedInfo;
358
+ /** @ 提及列表(从文本或 richText 提取的名字) */
359
+ atMentions?: AtMention[];
360
+ /**
361
+ * 通过 @picker 选中的真实钉钉用户的 dingtalkId 列表
362
+ * - 仅包含真实钉钉用户和机器人,不包含 agent 名
363
+ * - 用于排除真人:如果 atMentions 中的名字匹配到 agent,说明是 agent;
364
+ * 如果没匹配到 agent 且有 atUserDingtalkIds,则可能是真人
365
+ * - 注意:无法将 dingtalkId 映射到具体名字,因为 webhook 不提供此映射
366
+ */
367
+ atUserDingtalkIds?: string[];
303
368
  }
304
369
 
305
370
  /**
@@ -318,6 +383,12 @@ export interface SendMessageOptions {
318
383
  accountId?: string;
319
384
  storePath?: string;
320
385
  cardUpdateMode?: "append";
386
+ quotedRef?: QuotedRef;
387
+ /** Force markdown/text delivery even when messageType is "card". Bypasses card
388
+ * creation while preserving journal writes and other side-effects. */
389
+ forceMarkdown?: boolean;
390
+ /** Allowed local roots for sandbox/container media path resolution. */
391
+ mediaLocalRoots?: string[];
321
392
  }
322
393
 
323
394
  export interface DingTalkTrackingMetadata {
@@ -344,6 +415,18 @@ export interface SessionWebhookResponse {
344
415
  };
345
416
  }
346
417
 
418
+ /**
419
+ * Sub-agent routing options for parameterized message handling
420
+ */
421
+ export interface SubAgentOptions {
422
+ /** The agent ID to route to */
423
+ agentId: string;
424
+ /** Prefix to add to response messages (e.g., "[AgentName] ") */
425
+ responsePrefix: string;
426
+ /** The matched agent name */
427
+ matchedName: string;
428
+ }
429
+
347
430
  /**
348
431
  * Message handler parameters
349
432
  */
@@ -354,6 +437,19 @@ export interface HandleDingTalkMessageParams {
354
437
  sessionWebhook: string;
355
438
  log?: Logger;
356
439
  dingtalkConfig: DingTalkConfig;
440
+ /**
441
+ * When set, routes message to the specified sub-agent instead of main agent.
442
+ * This enables reuse of the main message handling logic for sub-agents.
443
+ */
444
+ subAgentOptions?: SubAgentOptions;
445
+ /**
446
+ * Pre-downloaded media for sub-agent calls.
447
+ * When set, skips media download to avoid duplication in recursive calls.
448
+ */
449
+ preDownloadedMedia?: {
450
+ mediaPath?: string;
451
+ mediaType?: string;
452
+ };
357
453
  }
358
454
 
359
455
  /**
@@ -479,7 +575,9 @@ export interface GatewayStopResult {
479
575
  /**
480
576
  * DingTalk channel plugin definition
481
577
  */
482
- export type DingTalkChannelPlugin = SDKChannelPlugin<ResolvedAccount & { configured: boolean }>;
578
+ export type DingTalkChannelPlugin = SDKChannelPlugin<ResolvedAccount & { configured: boolean }> & {
579
+ setupWizard?: ChannelSetupWizard;
580
+ };
483
581
 
484
582
  /**
485
583
  * Result of target resolution validation
@@ -553,6 +651,7 @@ export interface AICardInstance {
553
651
  processQueryKey?: string;
554
652
  accessToken: string;
555
653
  conversationId: string;
654
+ contextConversationId?: string;
556
655
  accountId?: string;
557
656
  storePath?: string;
558
657
  createdAt: number;
@@ -680,6 +779,8 @@ export function resolveDingTalkAccount(
680
779
  dmPolicy: dingtalk?.dmPolicy,
681
780
  groupPolicy: dingtalk?.groupPolicy,
682
781
  allowFrom: dingtalk?.allowFrom,
782
+ groupAllowFrom: dingtalk?.groupAllowFrom,
783
+ displayNameResolution: dingtalk?.displayNameResolution,
683
784
  journalTTLDays: dingtalk?.journalTTLDays,
684
785
  ackReaction: dingtalk?.ackReaction,
685
786
  debug: dingtalk?.debug,
@@ -707,6 +808,8 @@ export function resolveDingTalkAccount(
707
808
  feedbackLearningEnabled: dingtalk?.feedbackLearningEnabled,
708
809
  feedbackLearningAutoApply: dingtalk?.feedbackLearningAutoApply,
709
810
  feedbackLearningNoteTtlMs: dingtalk?.feedbackLearningNoteTtlMs,
811
+ convertMarkdownTables: dingtalk?.convertMarkdownTables,
812
+ cardAtSender: dingtalk?.cardAtSender,
710
813
  };
711
814
  return {
712
815
  ...config,
@@ -1,242 +0,0 @@
1
- import { readNamespaceJson, writeNamespaceJsonAtomic } from "./persistence-store";
2
-
3
- const QUOTE_JOURNAL_NAMESPACE = "quoted.msg-journal";
4
- const QUOTE_JOURNAL_VERSION = 1;
5
- export const DEFAULT_JOURNAL_TTL_DAYS = 7;
6
- const MAX_RECORDS_PER_SCOPE = 1000;
7
-
8
- type JournalEntry = {
9
- msgId: string;
10
- messageType: string;
11
- text?: string;
12
- createdAt: number;
13
- };
14
-
15
- type QuoteJournalState = {
16
- version: number;
17
- updatedAt: number;
18
- records: JournalEntry[];
19
- };
20
-
21
- const stateCache = new Map<string, QuoteJournalState>();
22
-
23
- function getScopeKey(params: {
24
- storePath: string;
25
- accountId: string;
26
- conversationId: string | null;
27
- }): string {
28
- return JSON.stringify([
29
- params.storePath,
30
- params.accountId,
31
- params.conversationId || null,
32
- ]);
33
- }
34
-
35
- function fallbackState(): QuoteJournalState {
36
- return {
37
- version: QUOTE_JOURNAL_VERSION,
38
- updatedAt: Date.now(),
39
- records: [],
40
- };
41
- }
42
-
43
- function normalizeEntry(entry: unknown): JournalEntry | null {
44
- if (!entry || typeof entry !== "object") {
45
- return null;
46
- }
47
- const candidate = entry as Partial<JournalEntry>;
48
- if (typeof candidate.msgId !== "string" || typeof candidate.createdAt !== "number") {
49
- return null;
50
- }
51
- return {
52
- msgId: candidate.msgId,
53
- messageType: typeof candidate.messageType === "string" ? candidate.messageType : "text",
54
- text: typeof candidate.text === "string" ? candidate.text : undefined,
55
- createdAt: candidate.createdAt,
56
- };
57
- }
58
-
59
- function normalizeState(parsed: Partial<QuoteJournalState>): QuoteJournalState {
60
- const records = Array.isArray(parsed.records)
61
- ? parsed.records.map((entry) => normalizeEntry(entry)).filter((entry): entry is JournalEntry => entry !== null)
62
- : [];
63
- return {
64
- version: typeof parsed.version === "number" ? parsed.version : QUOTE_JOURNAL_VERSION,
65
- updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
66
- records,
67
- };
68
- }
69
-
70
- function loadState(params: {
71
- storePath: string;
72
- accountId: string;
73
- conversationId: string | null;
74
- }): QuoteJournalState {
75
- const scopeKey = getScopeKey(params);
76
- const cached = stateCache.get(scopeKey);
77
- if (cached) {
78
- return cached;
79
- }
80
-
81
- const persisted = readNamespaceJson<Partial<QuoteJournalState>>(QUOTE_JOURNAL_NAMESPACE, {
82
- storePath: params.storePath,
83
- scope: { accountId: params.accountId, conversationId: params.conversationId || undefined },
84
- format: "json",
85
- fallback: fallbackState(),
86
- });
87
- const normalized = normalizeState(persisted);
88
- stateCache.set(scopeKey, normalized);
89
- return normalized;
90
- }
91
-
92
- function writeState(params: {
93
- storePath: string;
94
- accountId: string;
95
- conversationId: string | null;
96
- state: QuoteJournalState;
97
- }): void {
98
- stateCache.set(getScopeKey(params), params.state);
99
- writeNamespaceJsonAtomic(QUOTE_JOURNAL_NAMESPACE, {
100
- storePath: params.storePath,
101
- scope: { accountId: params.accountId, conversationId: params.conversationId || undefined },
102
- format: "json",
103
- data: params.state,
104
- });
105
- }
106
-
107
- function pruneByTtl(records: JournalEntry[], ttlDays: number, nowMs: number): JournalEntry[] {
108
- if (!ttlDays || ttlDays <= 0) {
109
- return records;
110
- }
111
- const cutoff = nowMs - ttlDays * 24 * 60 * 60 * 1000;
112
- return records.filter((entry) => entry.createdAt >= cutoff);
113
- }
114
-
115
- function capRecords(records: JournalEntry[]): JournalEntry[] {
116
- if (records.length <= MAX_RECORDS_PER_SCOPE) {
117
- return records;
118
- }
119
- return records.slice(-MAX_RECORDS_PER_SCOPE);
120
- }
121
-
122
- export function appendQuoteJournalEntry(params: {
123
- storePath: string;
124
- accountId: string;
125
- conversationId: string | null;
126
- msgId: string;
127
- messageType: string;
128
- text?: string;
129
- createdAt: number;
130
- ttlDays?: number;
131
- nowMs?: number;
132
- }): void {
133
- const now = params.nowMs ?? Date.now();
134
- const ttlDays = params.ttlDays ?? DEFAULT_JOURNAL_TTL_DAYS;
135
- const state = loadState(params);
136
- const records = pruneByTtl(state.records, ttlDays, now);
137
- records.push({
138
- msgId: params.msgId,
139
- messageType: params.messageType,
140
- text: params.text,
141
- createdAt: params.createdAt,
142
- });
143
- const cappedRecords = capRecords(records);
144
- writeState({
145
- storePath: params.storePath,
146
- accountId: params.accountId,
147
- conversationId: params.conversationId,
148
- state: {
149
- version: QUOTE_JOURNAL_VERSION,
150
- updatedAt: now,
151
- records: cappedRecords,
152
- },
153
- });
154
- }
155
-
156
- export function cleanupExpiredQuoteJournalEntries(params: {
157
- storePath: string;
158
- accountId: string;
159
- conversationId: string | null;
160
- ttlDays: number;
161
- nowMs?: number;
162
- }): number {
163
- const now = params.nowMs ?? Date.now();
164
- const state = loadState(params);
165
- const kept = pruneByTtl(state.records, params.ttlDays, now);
166
- const removed = state.records.length - kept.length;
167
- if (removed > 0) {
168
- writeState({
169
- storePath: params.storePath,
170
- accountId: params.accountId,
171
- conversationId: params.conversationId,
172
- state: {
173
- version: QUOTE_JOURNAL_VERSION,
174
- updatedAt: now,
175
- records: kept,
176
- },
177
- });
178
- }
179
- return removed;
180
- }
181
-
182
- export function resolveQuotedMessageById(params: {
183
- storePath: string;
184
- accountId: string;
185
- conversationId: string | null;
186
- originalMsgId: string;
187
- ttlDays?: number;
188
- nowMs?: number;
189
- }): { msgId: string; text?: string; createdAt: number } | null {
190
- const state = loadState(params);
191
- const now = params.nowMs ?? Date.now();
192
- const ttlDays = params.ttlDays ?? DEFAULT_JOURNAL_TTL_DAYS;
193
- const records = capRecords(pruneByTtl(state.records, ttlDays, now));
194
- for (let i = records.length - 1; i >= 0; i--) {
195
- const entry = records[i];
196
- if (entry.msgId === params.originalMsgId) {
197
- return { msgId: entry.msgId, text: entry.text, createdAt: entry.createdAt };
198
- }
199
- }
200
- return null;
201
- }
202
-
203
- export async function appendOutboundToQuoteJournal(params: {
204
- storePath: string;
205
- accountId: string;
206
- conversationId: string | null;
207
- messageId?: string;
208
- text?: string;
209
- messageType?: string;
210
- log?: unknown;
211
- }): Promise<void> {
212
- try {
213
- if (!params.messageId) {
214
- return;
215
- }
216
- appendQuoteJournalEntry({
217
- storePath: params.storePath,
218
- accountId: params.accountId,
219
- conversationId: params.conversationId || null,
220
- msgId: params.messageId,
221
- messageType: params.messageType || "outbound",
222
- text: params.text,
223
- createdAt: Date.now(),
224
- });
225
- } catch (err) {
226
- (params.log as { debug?: (message: string) => void } | undefined)?.debug?.(
227
- `[quote-journal] appendOutbound failed: ${String(err)}`,
228
- );
229
- }
230
- }
231
-
232
- export async function appendProactiveOutboundJournal(params: {
233
- storePath: string;
234
- accountId: string;
235
- conversationId: string | null;
236
- messageId?: string;
237
- text?: string;
238
- messageType?: string;
239
- log?: unknown;
240
- }): Promise<void> {
241
- return appendOutboundToQuoteJournal(params);
242
- }