@soimy/dingtalk 3.2.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +796 -42
  3. package/index.ts +62 -0
  4. package/package.json +4 -2
  5. package/src/access-control.ts +83 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  8. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  9. package/src/ack-reaction-classifier.ts +75 -0
  10. package/src/ack-reaction-service.ts +182 -0
  11. package/src/attachment-text-extractor.ts +148 -0
  12. package/src/card-callback-service.ts +119 -0
  13. package/src/card-draft-controller.ts +114 -0
  14. package/src/card-service.ts +666 -26
  15. package/src/channel.ts +455 -150
  16. package/src/config-schema.ts +64 -6
  17. package/src/config.ts +161 -5
  18. package/src/connection-manager.ts +354 -47
  19. package/src/dedup.ts +1 -0
  20. package/src/docs-service.ts +198 -0
  21. package/src/draft-stream-loop.ts +119 -0
  22. package/src/feedback-learning-service.ts +643 -0
  23. package/src/feedback-learning-store.ts +543 -0
  24. package/src/group-members-store.ts +48 -14
  25. package/src/inbound-handler.ts +1374 -259
  26. package/src/learning-command-service.ts +339 -0
  27. package/src/media-utils.ts +94 -50
  28. package/src/message-context-store.ts +787 -0
  29. package/src/message-utils.ts +487 -46
  30. package/src/messaging/quoted-context.ts +269 -0
  31. package/src/messaging/quoted-ref.ts +97 -0
  32. package/src/onboarding.ts +96 -1
  33. package/src/peer-id-registry.ts +102 -0
  34. package/src/persistence-store.ts +131 -0
  35. package/src/quoted-file-service.ts +385 -0
  36. package/src/reply-strategy-card.ts +225 -0
  37. package/src/reply-strategy-markdown.ts +55 -0
  38. package/src/reply-strategy-with-reaction.ts +190 -0
  39. package/src/reply-strategy.ts +72 -0
  40. package/src/send-service.ts +267 -45
  41. package/src/session-command-service.ts +147 -0
  42. package/src/session-lock.ts +2 -0
  43. package/src/session-peer-store.ts +77 -0
  44. package/src/session-routing.ts +33 -0
  45. package/src/targeting/agent-name-matcher.ts +148 -0
  46. package/src/targeting/agent-routing.ts +181 -0
  47. package/src/targeting/target-directory-adapter.ts +151 -0
  48. package/src/targeting/target-directory-store.ts +396 -0
  49. package/src/targeting/target-input.ts +62 -0
  50. package/src/types.ts +261 -28
  51. package/src/utils.ts +231 -12
@@ -1,6 +1,13 @@
1
1
  import { z } from "zod";
2
+ import { DEFAULT_MESSAGE_CONTEXT_TTL_DAYS } from "./message-context-store";
2
3
 
3
- const DingTalkAccountConfigSchema = z.object({
4
+ const AckReactionSchema = z.union([
5
+ z.literal(""),
6
+ z.enum(["off", "emoji", "kaomoji"]),
7
+ z.string().min(1),
8
+ ]);
9
+
10
+ const DingTalkAccountConfigShape = {
4
11
  /** Account name (optional display name) */
5
12
  name: z.string().optional(),
6
13
 
@@ -25,17 +32,24 @@ const DingTalkAccountConfigSchema = z.object({
25
32
  /** Direct message policy: open, pairing, or allowlist */
26
33
  dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional().default("open"),
27
34
 
28
- /** Group message policy: open or allowlist */
29
- groupPolicy: z.enum(["open", "allowlist"]).optional().default("open"),
35
+ /** Group message policy: open, allowlist, or disabled */
36
+ groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional().default("open"),
30
37
 
31
38
  /** List of allowed user IDs for allowlist policy */
32
39
  allowFrom: z.array(z.string()).optional(),
33
40
 
41
+ /** List of allowed user IDs for group allowlist policy */
42
+ groupAllowFrom: z.array(z.string()).optional(),
43
+
44
+ /** Default disabled. Enabling "all" allows learned displayName lookup but may misroute on stale/duplicate names and is available to all callers until upstream exposes requester authz context. */
45
+ displayNameResolution: z.enum(["disabled", "all"]).optional().default("disabled"),
46
+
34
47
  mediaUrlAllowlist: z.array(z.string()).optional(),
35
48
 
36
- /** Show thinking indicator while processing */
37
- showThinking: z.boolean().optional().default(true),
49
+ /** Native ack reaction mode: off, emoji, or kaomoji */
50
+ ackReaction: AckReactionSchema.optional(),
38
51
 
52
+ journalTTLDays: z.number().int().min(1).optional().default(DEFAULT_MESSAGE_CONTEXT_TTL_DAYS),
39
53
  /** Enable debug logging */
40
54
  debug: z.boolean().optional().default(false),
41
55
 
@@ -60,6 +74,8 @@ const DingTalkAccountConfigSchema = z.object({
60
74
  z.string(),
61
75
  z.object({
62
76
  systemPrompt: z.string().optional(),
77
+ requireMention: z.boolean().optional(),
78
+ groupAllowFrom: z.array(z.string()).optional(),
63
79
  }),
64
80
  )
65
81
  .optional(),
@@ -81,12 +97,19 @@ const DingTalkAccountConfigSchema = z.object({
81
97
  /** Maximum number of runtime reconnect cycles before giving up (default: 10) */
82
98
  maxReconnectCycles: z.number().int().min(1).optional().default(10),
83
99
 
100
+ /** Maximum time (ms) for a single reconnect cycle before starting a new cycle (default: 50000) */
101
+ reconnectDeadlineMs: z.number().int().min(5000).optional().default(50000),
102
+
84
103
  /** Whether to use ConnectionManager (default: true). When false, rely on DWClient native keepAlive+autoReconnect. */
85
104
  useConnectionManager: z.boolean().optional().default(true),
86
105
 
87
106
  /** Maximum inbound media file size in MB (overrides runtime default when set) */
88
107
  mediaMaxMb: z.number().int().min(1).optional(),
89
108
 
109
+ /** Whether to enable underlying stream keepAlive heartbeat; defaults to !useConnectionManager when omitted */
110
+ keepAlive: z.boolean().optional(),
111
+ /** Bypass system/global HTTP(S) proxy for DingTalk outbound send/card/upload APIs */
112
+ bypassProxyForSend: z.boolean().optional().default(false),
90
113
  proactivePermissionHint: z
91
114
  .object({
92
115
  enabled: z.boolean().optional().default(true),
@@ -94,7 +117,42 @@ const DingTalkAccountConfigSchema = z.object({
94
117
  })
95
118
  .optional()
96
119
  .default({ enabled: true, cooldownHours: 24 }),
97
- });
120
+
121
+ /** Enable real-time card streaming (default: false).
122
+ * When true, card updates are streamed per-token with 300ms throttle for a smoother experience, at the cost of more API calls. */
123
+ cardRealTimeStream: z.boolean().optional().default(false),
124
+
125
+ /** AICard degrade duration in milliseconds after trigger errors (default: 30 minutes) */
126
+ aicardDegradeMs: z.number().int().min(60_000).optional().default(30 * 60 * 1000),
127
+
128
+ /** Enable local learning loop (default: false) */
129
+ learningEnabled: z.boolean().optional(),
130
+
131
+ /** Auto-apply generated reflections into session notes/global rules (default: false) */
132
+ learningAutoApply: z.boolean().optional(),
133
+
134
+ /** Session learning note TTL in milliseconds (default: 6 hours) */
135
+ learningNoteTtlMs: z.number().int().min(60_000).optional(),
136
+
137
+ /** @deprecated Use learningEnabled */
138
+ feedbackLearningEnabled: z.boolean().optional(),
139
+
140
+ /** @deprecated Use learningAutoApply */
141
+ feedbackLearningAutoApply: z.boolean().optional(),
142
+
143
+ /** @deprecated Use learningNoteTtlMs */
144
+ feedbackLearningNoteTtlMs: z.number().int().min(60_000).optional(),
145
+
146
+ /** Whether to convert markdown tables to plain text for better rendering on some clients (default: true) */
147
+ convertMarkdownTables: z.boolean().optional().default(true),
148
+
149
+ /** @mention the sender after card finalization in group chats.
150
+ * Set to a non-empty string (e.g. "✅ 回复完成") to enable — the value is used as the message text.
151
+ * Leave empty or omit to disable. */
152
+ cardAtSender: z.string().optional(),
153
+ } as const;
154
+
155
+ const DingTalkAccountConfigSchema = z.object(DingTalkAccountConfigShape);
98
156
 
99
157
  /**
100
158
  * DingTalk configuration schema using Zod
package/src/config.ts CHANGED
@@ -3,8 +3,61 @@ import * as path from "node:path";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
4
  import type { DingTalkConfig } from "./types";
5
5
 
6
+ const WINDOWS_ROOT_DIRECTORIES = new Set([
7
+ "Users",
8
+ "Program Files",
9
+ "Program Files (x86)",
10
+ "ProgramData",
11
+ "Windows",
12
+ "Documents and Settings",
13
+ ]);
14
+ const DEFAULT_LEARNING_NOTE_TTL_MS = 6 * 60 * 60 * 1000;
15
+
16
+ function normalizeLearningConfig(
17
+ config: DingTalkConfig,
18
+ options: { applyDefaults: boolean },
19
+ ): DingTalkConfig {
20
+ const learningEnabled = config.learningEnabled ?? config.feedbackLearningEnabled;
21
+ const learningAutoApply = config.learningAutoApply ?? config.feedbackLearningAutoApply;
22
+ const learningNoteTtlMs = config.learningNoteTtlMs ?? config.feedbackLearningNoteTtlMs;
23
+ return {
24
+ ...config,
25
+ learningEnabled: options.applyDefaults ? learningEnabled ?? false : learningEnabled,
26
+ learningAutoApply: options.applyDefaults ? learningAutoApply ?? false : learningAutoApply,
27
+ learningNoteTtlMs: options.applyDefaults
28
+ ? learningNoteTtlMs ?? DEFAULT_LEARNING_NOTE_TTL_MS
29
+ : learningNoteTtlMs,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Merge channel-level defaults into an account-specific config.
35
+ * Account-level values take precedence; `accounts` key is excluded to avoid recursion.
36
+ */
37
+ export function mergeAccountWithDefaults(
38
+ channelCfg: DingTalkConfig,
39
+ accountCfg: DingTalkConfig,
40
+ ): DingTalkConfig {
41
+ const { accounts: _accounts, ...defaults } = channelCfg;
42
+ const normalizedAccountCfg = normalizeLearningConfig(accountCfg, { applyDefaults: false });
43
+ const overrides: Partial<DingTalkConfig> = {};
44
+ for (const [key, value] of Object.entries(normalizedAccountCfg)) {
45
+ if (value !== undefined) {
46
+ Object.assign(overrides, { [key]: value });
47
+ }
48
+ }
49
+ return normalizeLearningConfig(
50
+ {
51
+ ...defaults,
52
+ ...overrides,
53
+ },
54
+ { applyDefaults: true },
55
+ );
56
+ }
57
+
6
58
  /**
7
59
  * Resolve DingTalk config for an account.
60
+ * Named accounts inherit channel-level defaults with account-level overrides.
8
61
  * Falls back to top-level config for single-account setups.
9
62
  */
10
63
  export function getConfig(cfg: OpenClawConfig, accountId?: string): DingTalkConfig {
@@ -14,10 +67,18 @@ export function getConfig(cfg: OpenClawConfig, accountId?: string): DingTalkConf
14
67
  }
15
68
 
16
69
  if (accountId && dingtalkCfg.accounts?.[accountId]) {
17
- return dingtalkCfg.accounts[accountId];
70
+ return mergeAccountWithDefaults(dingtalkCfg, dingtalkCfg.accounts[accountId]);
71
+ }
72
+
73
+ if (accountId) {
74
+ return normalizeLearningConfig(dingtalkCfg, { applyDefaults: true });
18
75
  }
19
76
 
20
- return dingtalkCfg;
77
+ if (dingtalkCfg.accounts && Object.keys(dingtalkCfg.accounts).length > 0) {
78
+ return dingtalkCfg;
79
+ }
80
+
81
+ return normalizeLearningConfig(dingtalkCfg, { applyDefaults: true });
21
82
  }
22
83
 
23
84
  export function isConfigured(cfg: OpenClawConfig, accountId?: string): boolean {
@@ -25,6 +86,19 @@ export function isConfigured(cfg: OpenClawConfig, accountId?: string): boolean {
25
86
  return Boolean(config.clientId && config.clientSecret);
26
87
  }
27
88
 
89
+ /**
90
+ * Resolve relative paths against a base directory, with intelligent platform-specific handling.
91
+ *
92
+ * Supports:
93
+ * - ~ and ~/ expansion to home directory
94
+ * - Absolute paths (Unix: /path, Windows: \path or C:\path)
95
+ * - Relative paths resolved against cwd
96
+ * - Windows absolute paths without drive letters (e.g., Users\name\.openclaw\file.txt)
97
+ * - Mixed path separators (/ and \)
98
+ *
99
+ * @param input - The path string to resolve
100
+ * @returns The resolved absolute path
101
+ */
28
102
  export function resolveRelativePath(input: string): string {
29
103
  const trimmed = input.trim();
30
104
  if (!trimmed) {
@@ -32,6 +106,8 @@ export function resolveRelativePath(input: string): string {
32
106
  }
33
107
 
34
108
  const segments = (value: string): string[] => value.split(/[\\/]+/).filter(Boolean);
109
+ const pathSegments = segments(trimmed);
110
+ const firstSegment = pathSegments[0];
35
111
 
36
112
  // Expand bare "~" and "~/" or "~\\" prefixes into the user home directory.
37
113
  if (trimmed === "~") {
@@ -41,13 +117,27 @@ export function resolveRelativePath(input: string): string {
41
117
  return path.resolve(os.homedir(), ...segments(trimmed.slice(2)));
42
118
  }
43
119
 
120
+ if (process.platform === "win32") {
121
+ // On Windows, OpenClaw may drop the leading "\" from root-based paths like
122
+ // "Users\name\.openclaw\workspace\file.xlsx". Only recover paths that start
123
+ // with well-known root directories to avoid misclassifying ordinary relative paths.
124
+ if (/^[a-zA-Z]:[\\/]/.test(trimmed)) {
125
+ return path.win32.normalize(trimmed);
126
+ }
127
+ if (firstSegment && /^[a-zA-Z]:$/.test(firstSegment)) {
128
+ return path.win32.resolve(`${firstSegment}\\`, ...pathSegments.slice(1));
129
+ }
130
+ if (firstSegment && WINDOWS_ROOT_DIRECTORIES.has(firstSegment)) {
131
+ return path.win32.resolve("\\", ...pathSegments);
132
+ }
133
+ }
44
134
  // Treat both "/" and "\\" as absolute root prefixes for cross-platform input.
45
135
  if (/^[\\/]/.test(trimmed)) {
46
- return path.resolve(path.sep, ...segments(trimmed));
136
+ return path.resolve(path.sep, ...pathSegments);
47
137
  }
48
138
 
49
139
  // Resolve relative path against cwd; supports mixed separators and "..\\..".
50
- return path.resolve(process.cwd(), ...segments(trimmed));
140
+ return path.resolve(process.cwd(), ...pathSegments);
51
141
  }
52
142
 
53
143
  export const resolveUserPath = resolveRelativePath;
@@ -55,7 +145,7 @@ export const resolveUserPath = resolveRelativePath;
55
145
  export function resolveGroupConfig(
56
146
  cfg: DingTalkConfig,
57
147
  groupId: string,
58
- ): { systemPrompt?: string } | undefined {
148
+ ): { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] } | undefined {
59
149
  // Group config supports exact match first, then wildcard fallback.
60
150
  const groups = cfg.groups;
61
151
  if (!groups) {
@@ -64,6 +154,72 @@ export function resolveGroupConfig(
64
154
  return groups[groupId] || groups["*"] || undefined;
65
155
  }
66
156
 
157
+ function hasOwn(obj: unknown, key: string): boolean {
158
+ return typeof obj === "object" && obj !== null && Object.prototype.hasOwnProperty.call(obj, key);
159
+ }
160
+
161
+ function resolveAgentIdentityEmoji(cfg: OpenClawConfig, agentId?: string | null): string | undefined {
162
+ const targetAgentId = String(agentId || "").trim();
163
+ if (!targetAgentId) {
164
+ return undefined;
165
+ }
166
+ const agents = Array.isArray((cfg as any)?.agents?.list) ? (cfg as any).agents.list : [];
167
+ const agent = agents.find((entry: any) => String(entry?.id || "").trim() === targetAgentId);
168
+ const emoji = typeof agent?.identity?.emoji === "string" ? agent.identity.emoji.trim() : "";
169
+ return emoji || undefined;
170
+ }
171
+
172
+ function normalizeAckReactionValue(value: unknown): string | undefined {
173
+ if (typeof value !== "string") {
174
+ return undefined;
175
+ }
176
+ const trimmed = value.trim();
177
+ if (!trimmed) {
178
+ return "";
179
+ }
180
+ const normalized = trimmed.toLowerCase();
181
+ if (normalized === "off") {
182
+ return "off";
183
+ }
184
+ if (normalized === "emoji") {
185
+ return "emoji";
186
+ }
187
+ if (normalized === "kaomoji") {
188
+ return "kaomoji";
189
+ }
190
+ if (trimmed === "🤔思考中") {
191
+ return "emoji";
192
+ }
193
+ return trimmed;
194
+ }
195
+
196
+ export function resolveAckReactionSetting(params: {
197
+ cfg: OpenClawConfig;
198
+ accountId?: string | null;
199
+ agentId?: string | null;
200
+ }): string | undefined {
201
+ const dingtalk = (params.cfg?.channels as any)?.dingtalk;
202
+ const accountId = String(params.accountId || "").trim();
203
+ const accountConfig =
204
+ accountId && dingtalk?.accounts && typeof dingtalk.accounts === "object"
205
+ ? dingtalk.accounts[accountId]
206
+ : undefined;
207
+
208
+ if (hasOwn(accountConfig, "ackReaction")) {
209
+ return normalizeAckReactionValue(accountConfig.ackReaction);
210
+ }
211
+ if (hasOwn(dingtalk, "ackReaction")) {
212
+ return normalizeAckReactionValue(dingtalk.ackReaction);
213
+ }
214
+
215
+ const messages = (params.cfg as any)?.messages;
216
+ if (hasOwn(messages, "ackReaction")) {
217
+ return normalizeAckReactionValue(messages.ackReaction);
218
+ }
219
+
220
+ return resolveAgentIdentityEmoji(params.cfg, params.agentId) || "👀";
221
+ }
222
+
67
223
  /**
68
224
  * Strip group/user prefixes used by CLI targeting.
69
225
  * Returns raw DingTalk target ID and whether caller explicitly requested a user target.