@soimy/dingtalk 3.5.0 → 3.5.2

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.
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import axios from "axios";
2
+ import axios from "./http-client";
3
3
  import { getAccessToken } from "./auth";
4
4
  import {
5
5
  isCardInTerminalState,
@@ -37,6 +37,53 @@ import type {
37
37
 
38
38
  export { detectMediaTypeFromExtension } from "./media-utils";
39
39
 
40
+ const MARKDOWN_LOCAL_IMAGE_RE =
41
+ /!\[([^\]]*)\]\((file:\/\/\/[^)]+|\/(?:tmp|var|private|Users|home|root)[^)]+|[A-Za-z]:[\\/][^)]+)\)/g;
42
+
43
+ function decodeMarkdownLocalImagePath(rawPath: string): string {
44
+ const unescapedPath = rawPath.replace(/\\ /g, " ");
45
+ if (unescapedPath.startsWith("file://")) {
46
+ try {
47
+ return decodeURIComponent(unescapedPath.replace("file://", ""));
48
+ } catch {
49
+ return unescapedPath.replace("file://", "");
50
+ }
51
+ }
52
+ return unescapedPath;
53
+ }
54
+
55
+ async function replaceMarkdownLocalImages(params: {
56
+ config: DingTalkConfig;
57
+ text: string;
58
+ log?: Logger;
59
+ mediaLocalRoots?: string[];
60
+ }): Promise<string> {
61
+ const matches = [...params.text.matchAll(MARKDOWN_LOCAL_IMAGE_RE)];
62
+ if (matches.length === 0) {
63
+ return params.text;
64
+ }
65
+
66
+ let result = params.text;
67
+ for (const match of matches) {
68
+ const [fullMatch, altText, rawPath] = match;
69
+ const mediaPath = decodeMarkdownLocalImagePath(rawPath);
70
+ const uploadResult = await uploadMedia(params.config, mediaPath, "image", params.log, {
71
+ mediaLocalRoots: params.mediaLocalRoots,
72
+ });
73
+
74
+ if (!uploadResult?.mediaId) {
75
+ params.log?.warn?.(
76
+ `[DingTalk] Markdown local image upload failed, keep original reference: ${mediaPath}`,
77
+ );
78
+ continue;
79
+ }
80
+
81
+ result = result.replace(fullMatch, () => `![${altText}](${uploadResult.mediaId})`);
82
+ }
83
+
84
+ return result;
85
+ }
86
+
40
87
  type ProactiveTextSendResult = AxiosResponse | { tracking: DingTalkTrackingMetadata };
41
88
 
42
89
  function isTrackingResult(result: ProactiveTextSendResult): result is { tracking: DingTalkTrackingMetadata } {
@@ -225,7 +272,7 @@ export async function sendProactiveTextOrMarkdown(
225
272
 
226
273
  // In card mode, use card API to avoid oToMessages/batchSend permission requirement.
227
274
  const messageType = config.messageType || "markdown";
228
- if (messageType === "card" && config.cardTemplateId && !options.forceMarkdown) {
275
+ if (messageType === "card" && !options.forceMarkdown) {
229
276
  log?.debug?.(
230
277
  `[DingTalk] Using card API for proactive message to user ${resolvedTarget}${proactiveRiskTag}`,
231
278
  );
@@ -252,7 +299,16 @@ export async function sendProactiveTextOrMarkdown(
252
299
  ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
253
300
  : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
254
301
 
255
- const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
302
+ const textWithUploadedLocalImages = await replaceMarkdownLocalImages({
303
+ config,
304
+ text,
305
+ log,
306
+ mediaLocalRoots: options.mediaLocalRoots,
307
+ });
308
+ const normalizedText =
309
+ config.convertMarkdownTables !== false
310
+ ? convertMarkdownTablesToPlainText(textWithUploadedLocalImages)
311
+ : textWithUploadedLocalImages;
256
312
  const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "OpenClaw 提醒");
257
313
 
258
314
  log?.debug?.(
@@ -531,7 +587,16 @@ export async function sendBySession(
531
587
  }
532
588
 
533
589
  // Fallback to text/markdown reply payload.
534
- const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
590
+ const textWithUploadedLocalImages = await replaceMarkdownLocalImages({
591
+ config,
592
+ text,
593
+ log,
594
+ mediaLocalRoots: options.mediaLocalRoots,
595
+ });
596
+ const normalizedText =
597
+ config.convertMarkdownTables !== false
598
+ ? convertMarkdownTablesToPlainText(textWithUploadedLocalImages)
599
+ : textWithUploadedLocalImages;
535
600
  const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "Clawdbot 消息");
536
601
  const chunks = splitMarkdownChunks(normalizedText, DINGTALK_TEXT_CHUNK_LIMIT);
537
602
 
@@ -582,20 +647,18 @@ export async function sendMessage(
582
647
  return { ok: true };
583
648
  }
584
649
 
585
- if (config.cardTemplateId) {
586
- const proactiveResult = await sendProactiveCardText(config, conversationId, text, log);
587
- if (!proactiveResult.ok) {
588
- return { ok: false, error: proactiveResult.error || "Card send failed" };
589
- }
590
- return {
591
- ok: true,
592
- tracking: {
593
- processQueryKey: proactiveResult.processQueryKey,
594
- outTrackId: proactiveResult.outTrackId,
595
- cardInstanceId: proactiveResult.cardInstanceId,
596
- },
597
- };
650
+ const proactiveResult = await sendProactiveCardText(config, conversationId, text, log);
651
+ if (!proactiveResult.ok) {
652
+ return { ok: false, error: proactiveResult.error || "Card send failed" };
598
653
  }
654
+ return {
655
+ ok: true,
656
+ tracking: {
657
+ processQueryKey: proactiveResult.processQueryKey,
658
+ outTrackId: proactiveResult.outTrackId,
659
+ cardInstanceId: proactiveResult.cardInstanceId,
660
+ },
661
+ };
599
662
  }
600
663
  }
601
664
 
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
11
+ import { maybeResolveTextAlias } from "openclaw/plugin-sdk/command-auth";
11
12
  import { resolveAtAgents } from "./agent-name-matcher";
12
13
  import { resolveRobotCode } from "../config";
13
14
  import { parseLearnCommand } from "../learning-command-service";
@@ -93,16 +94,22 @@ export async function resolveSubAgentRoute(params: {
93
94
  const atMentions = extractedContent.atMentions || [];
94
95
  // DM has no @picker list from DingTalk; only group chats provide atUsers for real-user hints.
95
96
  const atUserDingtalkIds = isGroup ? extractedContent.atUserDingtalkIds : undefined;
96
- // Strip quoted prefix before checking /learn to avoid false positives
97
- // when the quoted message itself contains a /learn command.
97
+ // Strip quoted prefix before checking commands to avoid false positives
98
+ // when the quoted message itself contains a command.
98
99
  const textForCommandCheck = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
99
100
  const isLearnCommand = parseLearnCommand(textForCommandCheck).scope !== "unknown";
101
+ // Slash commands like /new, /stop, /reasoning etc. must bypass sub-agent
102
+ // routing so they reach the framework's own command handling layer.
103
+ // Strip leading @mention tokens first since DM text may look like "@Agent /new".
104
+ const textWithoutMentions = textForCommandCheck.replace(/^(?:@\S+\s+)*/u, "").trim();
105
+ const isSlashCommand = maybeResolveTextAlias(textWithoutMentions, cfg) !== null;
100
106
 
101
107
  if (
102
108
  atMentions.length === 0 ||
103
109
  !cfg.agents?.list ||
104
110
  cfg.agents.list.length === 0 ||
105
- isLearnCommand
111
+ isLearnCommand ||
112
+ isSlashCommand
106
113
  ) {
107
114
  return null;
108
115
  }
@@ -173,7 +180,7 @@ export async function dispatchSubAgents(params: {
173
180
  dingtalkConfig,
174
181
  subAgentOptions: {
175
182
  agentId: agentMatch.agentId,
176
- responsePrefix: `[${sanitizeAgentName(agentMatch.matchedName)}] `,
183
+ responsePrefix: `> 🤖 **${sanitizeAgentName(agentMatch.matchedName)}**:\n\n`,
177
184
  matchedName: agentMatch.matchedName,
178
185
  },
179
186
  preDownloadedMedia,
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { readNamespaceJson, writeNamespaceJsonAtomic } from "./persistence-store";
3
+ import { readNamespaceJson, writeNamespaceJsonAtomic } from "../persistence-store";
4
4
 
5
5
  const GROUP_MEMBERS_NAMESPACE = "members.group-roster";
6
6
 
package/src/types.ts CHANGED
@@ -44,7 +44,9 @@ export interface DingTalkConfig extends OpenClawConfig {
44
44
  ackReaction?: AckReactionConfigValue;
45
45
  debug?: boolean;
46
46
  messageType?: "markdown" | "card";
47
+ /** @deprecated 已固定使用内置模板契约 */
47
48
  cardTemplateId?: string;
49
+ /** @deprecated 已固定使用内置模板契约 */
48
50
  cardTemplateKey?: string;
49
51
  groups?: Record<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
50
52
  accounts?: Record<string, DingTalkConfig>;
@@ -103,7 +105,9 @@ export interface DingTalkChannelConfig {
103
105
  ackReaction?: AckReactionConfigValue;
104
106
  debug?: boolean;
105
107
  messageType?: "markdown" | "card";
108
+ /** @deprecated 已固定使用内置模板契约 */
106
109
  cardTemplateId?: string;
110
+ /** @deprecated 已固定使用内置模板契约 */
107
111
  cardTemplateKey?: string;
108
112
  groups?: Record<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
109
113
  accounts?: Record<string, DingTalkConfig>;
@@ -618,6 +622,7 @@ export const AICardStatus = {
618
622
  PROCESSING: "1",
619
623
  INPUTING: "2",
620
624
  FINISHED: "3",
625
+ STOPPED: "4",
621
626
  FAILED: "5",
622
627
  } as const;
623
628
 
@@ -639,7 +644,7 @@ export interface AICardInstance {
639
644
  storePath?: string;
640
645
  createdAt: number;
641
646
  lastUpdated: number;
642
- state: AICardState; // Current card state: PROCESSING, INPUTING, FINISHED, FAILED
647
+ state: AICardState; // Current card state: PROCESSING, INPUTING, FINISHED, STOPPED, FAILED
643
648
  config?: DingTalkConfig; // Store config reference for token refresh
644
649
  lastStreamedContent?: string;
645
650
  outTrackId?: string;
package/src/utils.ts CHANGED
@@ -5,6 +5,171 @@ import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import type { Logger, RetryOptions } from "./types";
7
7
 
8
+ type PluginDebugLogParams = {
9
+ accountId: string;
10
+ storePath?: string;
11
+ debug?: boolean;
12
+ baseLog?: Logger;
13
+ now?: () => Date;
14
+ fsImpl?: Pick<typeof fs, "appendFileSync" | "mkdirSync">;
15
+ };
16
+
17
+ type PluginDebugWriter = {
18
+ filePath: string;
19
+ warned: boolean;
20
+ directoryReady: boolean;
21
+ };
22
+
23
+ const pluginDebugWriters = new Map<string, PluginDebugWriter>();
24
+ const closedPluginDebugScopes = new Set<string>();
25
+
26
+ function padNumber(value: number, width = 2): string {
27
+ return String(value).padStart(width, "0");
28
+ }
29
+
30
+ function formatTimezoneOffset(date: Date): string {
31
+ const offsetMinutes = -date.getTimezoneOffset();
32
+ const sign = offsetMinutes >= 0 ? "+" : "-";
33
+ const absoluteMinutes = Math.abs(offsetMinutes);
34
+ const hours = Math.floor(absoluteMinutes / 60);
35
+ const minutes = absoluteMinutes % 60;
36
+ return `${sign}${padNumber(hours)}:${padNumber(minutes)}`;
37
+ }
38
+
39
+ function formatPluginDebugTimestamp(date: Date): string {
40
+ return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())} ${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}.${padNumber(date.getMilliseconds(), 3)}${formatTimezoneOffset(date)}`;
41
+ }
42
+
43
+ function formatPluginDebugDate(date: Date): string {
44
+ return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}`;
45
+ }
46
+
47
+ function resolvePluginDebugLogFilePath(params: { storePath: string; accountId: string; date: Date }): string {
48
+ return path.join(
49
+ path.dirname(params.storePath),
50
+ "logs",
51
+ "dingtalk",
52
+ params.accountId,
53
+ `debug-${formatPluginDebugDate(params.date)}.log`,
54
+ );
55
+ }
56
+
57
+ function formatPluginDebugLine(params: { accountId: string; date: Date; message: string }): string {
58
+ return `[${formatPluginDebugTimestamp(params.date)}] [debug] [dingtalk] [account:${params.accountId}] ${params.message}`;
59
+ }
60
+
61
+ function buildPluginDebugWriterKey(params: { storePath: string; accountId: string; date: Date }): string {
62
+ return JSON.stringify([params.storePath, params.accountId, formatPluginDebugDate(params.date)]);
63
+ }
64
+
65
+ function buildPluginDebugScopeKey(params: { storePath: string; accountId: string }): string {
66
+ return JSON.stringify([params.storePath, params.accountId]);
67
+ }
68
+
69
+ function resolvePluginDebugWriter(params: {
70
+ storePath: string;
71
+ accountId: string;
72
+ date: Date;
73
+ }): PluginDebugWriter {
74
+ const key = buildPluginDebugWriterKey(params);
75
+ const existing = pluginDebugWriters.get(key);
76
+ if (existing) {
77
+ return existing;
78
+ }
79
+
80
+ const created = {
81
+ filePath: resolvePluginDebugLogFilePath(params),
82
+ warned: false,
83
+ directoryReady: false,
84
+ };
85
+ pluginDebugWriters.set(key, created);
86
+ return created;
87
+ }
88
+
89
+ export function resolvePluginDebugLog(params: PluginDebugLogParams): Logger {
90
+ const baseLog = params.baseLog;
91
+ const fsImpl = params.fsImpl ?? fs;
92
+ const scopeKey = params.storePath
93
+ ? buildPluginDebugScopeKey({ storePath: params.storePath, accountId: params.accountId })
94
+ : undefined;
95
+
96
+ if (scopeKey) {
97
+ closedPluginDebugScopes.delete(scopeKey);
98
+ }
99
+
100
+ return {
101
+ debug: (message: string) => {
102
+ if (!params.debug) {
103
+ baseLog?.debug?.(message);
104
+ return;
105
+ }
106
+
107
+ const date = params.now ? params.now() : new Date();
108
+ const line = formatPluginDebugLine({
109
+ accountId: params.accountId,
110
+ date,
111
+ message,
112
+ });
113
+
114
+ try {
115
+ process.stdout.write(`${line}\n`);
116
+ } catch {
117
+ // Ignore stdout failures so plugin debug logging never breaks message handling.
118
+ }
119
+
120
+ if (params.storePath && scopeKey && !closedPluginDebugScopes.has(scopeKey)) {
121
+ const writer = resolvePluginDebugWriter({
122
+ storePath: params.storePath,
123
+ accountId: params.accountId,
124
+ date,
125
+ });
126
+ try {
127
+ if (!writer.directoryReady) {
128
+ fsImpl.mkdirSync(path.dirname(writer.filePath), { recursive: true });
129
+ writer.directoryReady = true;
130
+ }
131
+ fsImpl.appendFileSync(writer.filePath, `${line}\n`, "utf8");
132
+ } catch (err) {
133
+ if (!writer.warned) {
134
+ writer.warned = true;
135
+ baseLog?.warn?.(
136
+ `[DingTalk] Plugin debug log file unavailable: accountId=${params.accountId} path=${writer.filePath} error=${getErrorMessage(err)}`,
137
+ );
138
+ }
139
+ }
140
+ }
141
+
142
+ try {
143
+ baseLog?.debug?.(message);
144
+ } catch {
145
+ // Ignore upstream debug failures so plugin-owned debug remains best-effort.
146
+ }
147
+ },
148
+ info: (message: string) => baseLog?.info?.(message),
149
+ warn: (message: string) => baseLog?.warn?.(message),
150
+ error: (message: string) => baseLog?.error?.(message),
151
+ };
152
+ }
153
+
154
+ export function closePluginDebugLog(params: { accountId: string; storePath?: string }): void {
155
+ if (!params.storePath) {
156
+ return;
157
+ }
158
+
159
+ const scopeKey = buildPluginDebugScopeKey({
160
+ storePath: params.storePath,
161
+ accountId: params.accountId,
162
+ });
163
+ closedPluginDebugScopes.add(scopeKey);
164
+
165
+ for (const key of pluginDebugWriters.keys()) {
166
+ const [writerStorePath, writerAccountId] = JSON.parse(key) as [string, string, string];
167
+ if (writerStorePath === params.storePath && writerAccountId === params.accountId) {
168
+ pluginDebugWriters.delete(key);
169
+ }
170
+ }
171
+ }
172
+
8
173
  /**
9
174
  * Mask sensitive fields in data for safe logging
10
175
  * Prevents PII leakage in debug logs