@soimy/dingtalk 3.3.0 → 3.4.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/src/types.ts CHANGED
@@ -19,6 +19,11 @@ import type {
19
19
  } from "openclaw/plugin-sdk";
20
20
  import { mergeAccountWithDefaults } from "./config";
21
21
 
22
+ export type AckReactionMode = "off" | "emoji" | "kaomoji";
23
+ // Accept arbitrary strings for backward compatibility; the recommended
24
+ // explicit modes remain: "off" | "emoji" | "kaomoji".
25
+ export type AckReactionConfigValue = string;
26
+
22
27
  export interface DingtalkPluginModule {
23
28
  id: string;
24
29
  name: string;
@@ -39,16 +44,18 @@ export interface DingTalkConfig extends OpenClawConfig {
39
44
  name?: string;
40
45
  enabled?: boolean;
41
46
  dmPolicy?: "open" | "pairing" | "allowlist";
42
- groupPolicy?: "open" | "allowlist";
47
+ groupPolicy?: "open" | "allowlist" | "disabled";
43
48
  allowFrom?: string[];
49
+ groupAllowFrom?: string[];
50
+ displayNameResolution?: "disabled" | "all";
44
51
  mediaUrlAllowlist?: string[];
45
52
  journalTTLDays?: number;
46
- ackReaction?: string;
53
+ ackReaction?: AckReactionConfigValue;
47
54
  debug?: boolean;
48
55
  messageType?: "markdown" | "card";
49
56
  cardTemplateId?: string;
50
57
  cardTemplateKey?: string;
51
- groups?: Record<string, { systemPrompt?: string }>;
58
+ groups?: Record<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
52
59
  accounts?: Record<string, DingTalkConfig>;
53
60
  // Connection robustness configuration
54
61
  maxConnectionAttempts?: number;
@@ -87,6 +94,10 @@ export interface DingTalkConfig extends OpenClawConfig {
87
94
  feedbackLearningAutoApply?: boolean;
88
95
  /** @deprecated Use learningNoteTtlMs */
89
96
  feedbackLearningNoteTtlMs?: number;
97
+ /** Whether to convert markdown tables to plain text for better rendering on some clients (default: true) */
98
+ convertMarkdownTables?: boolean;
99
+ /** @mention the sender after card finalization in group chats; value is the message text */
100
+ cardAtSender?: string;
90
101
  }
91
102
 
92
103
  /**
@@ -101,16 +112,18 @@ export interface DingTalkChannelConfig {
101
112
  agentId?: string;
102
113
  name?: string;
103
114
  dmPolicy?: "open" | "pairing" | "allowlist";
104
- groupPolicy?: "open" | "allowlist";
115
+ groupPolicy?: "open" | "allowlist" | "disabled";
105
116
  allowFrom?: string[];
117
+ groupAllowFrom?: string[];
118
+ displayNameResolution?: "disabled" | "all";
106
119
  mediaUrlAllowlist?: string[];
107
120
  journalTTLDays?: number;
108
- ackReaction?: string;
121
+ ackReaction?: AckReactionConfigValue;
109
122
  debug?: boolean;
110
123
  messageType?: "markdown" | "card";
111
124
  cardTemplateId?: string;
112
125
  cardTemplateKey?: string;
113
- groups?: Record<string, { systemPrompt?: string }>;
126
+ groups?: Record<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
114
127
  accounts?: Record<string, DingTalkConfig>;
115
128
  maxConnectionAttempts?: number;
116
129
  initialReconnectDelay?: number;
@@ -148,6 +161,10 @@ export interface DingTalkChannelConfig {
148
161
  feedbackLearningAutoApply?: boolean;
149
162
  /** @deprecated Use learningNoteTtlMs */
150
163
  feedbackLearningNoteTtlMs?: number;
164
+ /** Whether to convert markdown tables to plain text for better rendering on some clients (default: true) */
165
+ convertMarkdownTables?: boolean;
166
+ /** @mention the sender after card finalization in group chats; value is the message text */
167
+ cardAtSender?: string;
151
168
  }
152
169
 
153
170
  /**
@@ -207,6 +224,14 @@ export interface DingTalkInboundMessage {
207
224
  msgId: string;
208
225
  msgtype: string;
209
226
  createAt: number;
227
+ /**
228
+ * @ 提及的用户列表(消息顶层,与 text 同级)
229
+ * 包含通过 @picker 选中的所有真实钉钉用户和机器人
230
+ * 格式: [{ dingtalkId: "$:LWCP_v1:$xxx" }]
231
+ */
232
+ atUsers?: Array<{
233
+ dingtalkId: string;
234
+ }>;
210
235
  text?: {
211
236
  content: string;
212
237
  isReplyMsg?: boolean; // 是否是回复消息
@@ -218,6 +243,7 @@ export interface DingTalkInboundMessage {
218
243
  content?: {
219
244
  text?: string;
220
245
  downloadCode?: string;
246
+ fileName?: string;
221
247
  biz_custom_action_url?: string;
222
248
  richText?: Array<{
223
249
  msgType?: string;
@@ -242,6 +268,7 @@ export interface DingTalkInboundMessage {
242
268
  type: string;
243
269
  text?: string;
244
270
  atName?: string;
271
+ atUserId?: string;
245
272
  downloadCode?: string;
246
273
  }>;
247
274
  quoteContent?: string;
@@ -267,24 +294,58 @@ export interface DingTalkInboundMessage {
267
294
  sessionWebhook: string;
268
295
  }
269
296
 
297
+ export type QuotedRefKey = "msgId" | "processQueryKey" | "messageId" | "outTrackId" | "cardInstanceId";
298
+
299
+ export type AttachmentTextSource = "text" | "html" | "pdf" | "docx";
300
+
301
+ export interface QuotedRef {
302
+ targetDirection: "inbound" | "outbound";
303
+ key?: QuotedRefKey;
304
+ value?: string;
305
+ fallbackCreatedAt?: number;
306
+ }
307
+
270
308
  /**
271
309
  * Quoted/reply message metadata extracted from repliedMsg.
272
310
  * Populated when isReplyMsg is true; downstream handlers use these fields
273
311
  * to download quoted media or look up cached card content.
274
312
  */
275
313
  export interface QuotedInfo {
276
- prefix: string;
277
314
  mediaDownloadCode?: string;
278
315
  mediaType?: string;
279
316
  isQuotedFile?: boolean;
280
317
  isQuotedCard?: boolean;
281
318
  isQuotedDocCard?: boolean;
282
- docSpaceId?: string;
283
- docFileId?: string;
284
319
  cardCreatedAt?: number;
285
320
  processQueryKey?: string;
286
321
  fileCreatedAt?: number;
287
322
  msgId?: string;
323
+ previewText?: string;
324
+ previewMessageType?: string;
325
+ previewFileName?: string;
326
+ previewSenderId?: string;
327
+ }
328
+
329
+ /**
330
+ * @ 提及信息
331
+ */
332
+ export interface AtMention {
333
+ /** @ 显示的名字(去除 @ 前缀) */
334
+ name: string;
335
+ /** 钉钉用户 ID(如果是 @ 真人) */
336
+ userId?: string;
337
+ }
338
+
339
+ /**
340
+ * Agent 名字匹配结果
341
+ */
342
+ export interface AgentNameMatch {
343
+ /** 匹配到的 agent ID */
344
+ agentId: string;
345
+ /** 匹配来源:'name' | 'id' */
346
+ matchSource: "name" | "id";
347
+ /** 匹配到的名字 */
348
+ matchedName: string;
288
349
  }
289
350
 
290
351
  /**
@@ -300,6 +361,16 @@ export interface MessageContent {
300
361
  docSpaceId?: string;
301
362
  docFileId?: string;
302
363
  quoted?: QuotedInfo;
364
+ /** @ 提及列表(从文本或 richText 提取的名字) */
365
+ atMentions?: AtMention[];
366
+ /**
367
+ * 通过 @picker 选中的真实钉钉用户的 dingtalkId 列表
368
+ * - 仅包含真实钉钉用户和机器人,不包含 agent 名
369
+ * - 用于排除真人:如果 atMentions 中的名字匹配到 agent,说明是 agent;
370
+ * 如果没匹配到 agent 且有 atUserDingtalkIds,则可能是真人
371
+ * - 注意:无法将 dingtalkId 映射到具体名字,因为 webhook 不提供此映射
372
+ */
373
+ atUserDingtalkIds?: string[];
303
374
  }
304
375
 
305
376
  /**
@@ -318,6 +389,10 @@ export interface SendMessageOptions {
318
389
  accountId?: string;
319
390
  storePath?: string;
320
391
  cardUpdateMode?: "append";
392
+ quotedRef?: QuotedRef;
393
+ /** Force markdown/text delivery even when messageType is "card". Bypasses card
394
+ * creation while preserving journal writes and other side-effects. */
395
+ forceMarkdown?: boolean;
321
396
  }
322
397
 
323
398
  export interface DingTalkTrackingMetadata {
@@ -344,6 +419,18 @@ export interface SessionWebhookResponse {
344
419
  };
345
420
  }
346
421
 
422
+ /**
423
+ * Sub-agent routing options for parameterized message handling
424
+ */
425
+ export interface SubAgentOptions {
426
+ /** The agent ID to route to */
427
+ agentId: string;
428
+ /** Prefix to add to response messages (e.g., "[AgentName] ") */
429
+ responsePrefix: string;
430
+ /** The matched agent name */
431
+ matchedName: string;
432
+ }
433
+
347
434
  /**
348
435
  * Message handler parameters
349
436
  */
@@ -354,6 +441,19 @@ export interface HandleDingTalkMessageParams {
354
441
  sessionWebhook: string;
355
442
  log?: Logger;
356
443
  dingtalkConfig: DingTalkConfig;
444
+ /**
445
+ * When set, routes message to the specified sub-agent instead of main agent.
446
+ * This enables reuse of the main message handling logic for sub-agents.
447
+ */
448
+ subAgentOptions?: SubAgentOptions;
449
+ /**
450
+ * Pre-downloaded media for sub-agent calls.
451
+ * When set, skips media download to avoid duplication in recursive calls.
452
+ */
453
+ preDownloadedMedia?: {
454
+ mediaPath?: string;
455
+ mediaType?: string;
456
+ };
357
457
  }
358
458
 
359
459
  /**
@@ -553,6 +653,7 @@ export interface AICardInstance {
553
653
  processQueryKey?: string;
554
654
  accessToken: string;
555
655
  conversationId: string;
656
+ contextConversationId?: string;
556
657
  accountId?: string;
557
658
  storePath?: string;
558
659
  createdAt: number;
@@ -680,6 +781,8 @@ export function resolveDingTalkAccount(
680
781
  dmPolicy: dingtalk?.dmPolicy,
681
782
  groupPolicy: dingtalk?.groupPolicy,
682
783
  allowFrom: dingtalk?.allowFrom,
784
+ groupAllowFrom: dingtalk?.groupAllowFrom,
785
+ displayNameResolution: dingtalk?.displayNameResolution,
683
786
  journalTTLDays: dingtalk?.journalTTLDays,
684
787
  ackReaction: dingtalk?.ackReaction,
685
788
  debug: dingtalk?.debug,
@@ -707,6 +810,8 @@ export function resolveDingTalkAccount(
707
810
  feedbackLearningEnabled: dingtalk?.feedbackLearningEnabled,
708
811
  feedbackLearningAutoApply: dingtalk?.feedbackLearningAutoApply,
709
812
  feedbackLearningNoteTtlMs: dingtalk?.feedbackLearningNoteTtlMs,
813
+ convertMarkdownTables: dingtalk?.convertMarkdownTables,
814
+ cardAtSender: dingtalk?.cardAtSender,
710
815
  };
711
816
  return {
712
817
  ...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
- }