@soimy/dingtalk 2.7.0 → 3.0.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.
@@ -0,0 +1,156 @@
1
+ import type { DingTalkInboundMessage, MessageContent, SendMessageOptions } from "./types";
2
+
3
+ /**
4
+ * Auto-detect markdown usage and derive message title.
5
+ * Title extraction follows DingTalk markdown card title constraints.
6
+ */
7
+ export function detectMarkdownAndExtractTitle(
8
+ text: string,
9
+ options: SendMessageOptions,
10
+ defaultTitle: string,
11
+ ): { useMarkdown: boolean; title: string } {
12
+ const hasMarkdown = /^[#*>-]|[*_`#[\]]/.test(text) || text.includes("\n");
13
+ const useMarkdown = options.useMarkdown !== false && (options.useMarkdown || hasMarkdown);
14
+
15
+ const title =
16
+ options.title ||
17
+ (useMarkdown
18
+ ? text
19
+ .split("\n")[0]
20
+ .replace(/^[#*\s\->]+/, "")
21
+ .slice(0, 20) || defaultTitle
22
+ : defaultTitle);
23
+
24
+ return { useMarkdown, title };
25
+ }
26
+
27
+ export function extractMessageContent(data: DingTalkInboundMessage): MessageContent {
28
+ const msgtype = data.msgtype || "text";
29
+
30
+ // Normalize quote/reply metadata into a readable text prefix so the agent can understand message context.
31
+ const formatQuotedContent = (): string => {
32
+ const textField = data.text as any;
33
+
34
+ if (textField?.isReplyMsg && textField?.repliedMsg) {
35
+ const repliedMsg = textField.repliedMsg;
36
+ const content = repliedMsg?.content;
37
+
38
+ if (content?.text) {
39
+ const quoteText = content.text.trim();
40
+ if (quoteText) {
41
+ return `[引用消息: "${quoteText}"]\n\n`;
42
+ }
43
+ }
44
+
45
+ if (content?.richText && Array.isArray(content.richText)) {
46
+ const textParts: string[] = [];
47
+ for (const part of content.richText) {
48
+ if (part.msgType === "text" && part.content) {
49
+ textParts.push(part.content);
50
+ } else if (part.msgType === "emoji" || part.type === "emoji") {
51
+ textParts.push(part.content || "[表情]");
52
+ } else if (part.msgType === "picture" || part.type === "picture") {
53
+ textParts.push("[图片]");
54
+ } else if (part.msgType === "at" || part.type === "at") {
55
+ textParts.push(`@${part.content || part.atName || "某人"}`);
56
+ } else if (part.text) {
57
+ textParts.push(part.text);
58
+ }
59
+ }
60
+ const quoteText = textParts.join("").trim();
61
+ if (quoteText) {
62
+ return `[引用消息: "${quoteText}"]\n\n`;
63
+ }
64
+ }
65
+ }
66
+
67
+ // Some clients only send originalMsgId for rich media reply messages.
68
+ if (textField?.isReplyMsg && !textField?.repliedMsg && data.originalMsgId) {
69
+ return `[这是一条引用消息,原消息ID: ${data.originalMsgId}]\n\n`;
70
+ }
71
+
72
+ if (data.quoteMessage) {
73
+ const quoteText = data.quoteMessage.text?.content?.trim() || "";
74
+ if (quoteText) {
75
+ return `[引用消息: "${quoteText}"]\n\n`;
76
+ }
77
+ }
78
+
79
+ if (data.content?.quoteContent) {
80
+ return `[引用消息: "${data.content.quoteContent}"]\n\n`;
81
+ }
82
+
83
+ return "";
84
+ };
85
+
86
+ const quotedPrefix = formatQuotedContent();
87
+
88
+ // Unified extraction by DingTalk msgtype for downstream routing/agent processing.
89
+ if (msgtype === "text") {
90
+ return { text: quotedPrefix + (data.text?.content?.trim() || ""), messageType: "text" };
91
+ }
92
+
93
+ if (msgtype === "richText") {
94
+ const richTextParts = data.content?.richText || [];
95
+ let text = "";
96
+ let pictureDownloadCode: string | undefined;
97
+ // Keep first image downloadCode while preserving readable text and @mention parts.
98
+ for (const part of richTextParts) {
99
+ if (part.text && (part.type === "text" || part.type === undefined)) {
100
+ text += part.text;
101
+ }
102
+ if (part.type === "at" && part.atName) {
103
+ text += `@${part.atName} `;
104
+ }
105
+ if (part.type === "picture" && part.downloadCode && !pictureDownloadCode) {
106
+ pictureDownloadCode = part.downloadCode;
107
+ }
108
+ }
109
+ return {
110
+ text:
111
+ quotedPrefix + (text.trim() || (pictureDownloadCode ? "<media:image>" : "[富文本消息]")),
112
+ mediaPath: pictureDownloadCode,
113
+ mediaType: pictureDownloadCode ? "image" : undefined,
114
+ messageType: "richText",
115
+ };
116
+ }
117
+
118
+ if (msgtype === "picture") {
119
+ return {
120
+ text: "<media:image>",
121
+ mediaPath: data.content?.downloadCode,
122
+ mediaType: "image",
123
+ messageType: "picture",
124
+ };
125
+ }
126
+
127
+ if (msgtype === "audio") {
128
+ return {
129
+ text: data.content?.recognition || "<media:voice>",
130
+ mediaPath: data.content?.downloadCode,
131
+ mediaType: "audio",
132
+ messageType: "audio",
133
+ };
134
+ }
135
+
136
+ if (msgtype === "video") {
137
+ return {
138
+ text: "<media:video>",
139
+ mediaPath: data.content?.downloadCode,
140
+ mediaType: "video",
141
+ messageType: "video",
142
+ };
143
+ }
144
+
145
+ if (msgtype === "file") {
146
+ return {
147
+ text: `<media:file> (${data.content?.fileName || "文件"})`,
148
+ mediaPath: data.content?.downloadCode,
149
+ mediaType: "file",
150
+ messageType: "file",
151
+ };
152
+ }
153
+
154
+ // Fallback: preserve unknown msgtype as readable marker.
155
+ return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype };
156
+ }
package/src/onboarding.ts CHANGED
@@ -1,9 +1,9 @@
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';
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 = 'dingtalk' as const;
6
+ const channel = "dingtalk" as const;
7
7
 
8
8
  function isConfigured(account: DingTalkConfig): boolean {
9
9
  return Boolean(account.clientId && account.clientSecret);
@@ -23,7 +23,9 @@ function applyAccountNameToChannelSection(params: {
23
23
  name?: string;
24
24
  }): OpenClawConfig {
25
25
  const { cfg, channelKey, name } = params;
26
- if (!name) return cfg;
26
+ if (!name) {
27
+ return cfg;
28
+ }
27
29
  const base = cfg.channels?.[channelKey] as DingTalkChannelConfig | undefined;
28
30
  return {
29
31
  ...cfg,
@@ -64,15 +66,15 @@ async function promptDingTalkAccountId(options: {
64
66
  async function noteDingTalkHelp(prompter: WizardPrompter): Promise<void> {
65
67
  await prompter.note(
66
68
  [
67
- 'You need DingTalk application credentials.',
68
- '1. Visit https://open-dev.dingtalk.com/',
69
- '2. Create an enterprise internal application',
69
+ "You need DingTalk application credentials.",
70
+ "1. Visit https://open-dev.dingtalk.com/",
71
+ "2. Create an enterprise internal application",
70
72
  "3. Enable 'Robot' capability",
71
73
  "4. Configure message receiving mode as 'Stream mode'",
72
- '5. Copy Client ID (AppKey) and Client Secret (AppSecret)',
73
- `Docs: ${formatDocsLink('/channels/dingtalk', 'channels/dingtalk')}`,
74
- ].join('\n'),
75
- 'DingTalk setup'
74
+ "5. Copy Client ID (AppKey) and Client Secret (AppSecret)",
75
+ `Docs: ${formatDocsLink("/channels/dingtalk", "channels/dingtalk")}`,
76
+ ].join("\n"),
77
+ "DingTalk setup",
76
78
  );
77
79
  }
78
80
 
@@ -86,7 +88,7 @@ function applyAccountConfig(params: {
86
88
 
87
89
  const namedConfig = applyAccountNameToChannelSection({
88
90
  cfg,
89
- channelKey: 'dingtalk',
91
+ channelKey: "dingtalk",
90
92
  accountId,
91
93
  name: input.name,
92
94
  });
@@ -103,7 +105,7 @@ function applyAccountConfig(params: {
103
105
  ...(input.allowFrom && input.allowFrom.length > 0 ? { allowFrom: input.allowFrom } : {}),
104
106
  ...(input.messageType ? { messageType: input.messageType } : {}),
105
107
  ...(input.cardTemplateId ? { cardTemplateId: input.cardTemplateId } : {}),
106
- ...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {})
108
+ ...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {}),
107
109
  };
108
110
 
109
111
  if (useDefault) {
@@ -121,7 +123,8 @@ function applyAccountConfig(params: {
121
123
  }
122
124
 
123
125
  const accounts = (base as { accounts?: Record<string, unknown> }).accounts ?? {};
124
- const existingAccount = (base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[accountId] ?? {};
126
+ const existingAccount =
127
+ (base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[accountId] ?? {};
125
128
 
126
129
  return {
127
130
  ...namedConfig,
@@ -155,8 +158,8 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
155
158
  return Promise.resolve({
156
159
  channel,
157
160
  configured,
158
- statusLines: [`DingTalk: ${configured ? 'configured' : 'needs setup'}`],
159
- selectionHint: configured ? 'configured' : '钉钉企业机器人',
161
+ statusLines: [`DingTalk: ${configured ? "configured" : "needs setup"}`],
162
+ selectionHint: configured ? "configured" : "钉钉企业机器人",
160
163
  quickstartScore: configured ? 1 : 4,
161
164
  });
162
165
  },
@@ -168,7 +171,7 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
168
171
  accountId = await promptDingTalkAccountId({
169
172
  cfg,
170
173
  prompter,
171
- label: 'DingTalk',
174
+ label: "DingTalk",
172
175
  currentId: accountId,
173
176
  listAccountIds: listDingTalkAccountIds,
174
177
  defaultAccountId: DEFAULT_ACCOUNT_ID,
@@ -179,21 +182,21 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
179
182
  await noteDingTalkHelp(prompter);
180
183
 
181
184
  const clientId = await prompter.text({
182
- message: 'Client ID (AppKey)',
183
- placeholder: 'dingxxxxxxxx',
185
+ message: "Client ID (AppKey)",
186
+ placeholder: "dingxxxxxxxx",
184
187
  initialValue: resolved.clientId ?? undefined,
185
- validate: (value) => (String(value ?? '').trim() ? undefined : 'Required'),
188
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
186
189
  });
187
190
 
188
191
  const clientSecret = await prompter.text({
189
- message: 'Client Secret (AppSecret)',
190
- placeholder: 'xxx-xxx-xxx-xxx',
192
+ message: "Client Secret (AppSecret)",
193
+ placeholder: "xxx-xxx-xxx-xxx",
191
194
  initialValue: resolved.clientSecret ?? undefined,
192
- validate: (value) => (String(value ?? '').trim() ? undefined : 'Required'),
195
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
193
196
  });
194
197
 
195
198
  const wantsFullConfig = await prompter.confirm({
196
- message: 'Configure robot code, corp ID, and agent ID? (recommended for full features)',
199
+ message: "Configure robot code, corp ID, and agent ID? (recommended for full features)",
197
200
  initialValue: false,
198
201
  });
199
202
 
@@ -205,100 +208,100 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
205
208
  robotCode =
206
209
  String(
207
210
  await prompter.text({
208
- message: 'Robot Code',
209
- placeholder: 'dingxxxxxxxx',
211
+ message: "Robot Code",
212
+ placeholder: "dingxxxxxxxx",
210
213
  initialValue: resolved.robotCode ?? undefined,
211
- })
214
+ }),
212
215
  ).trim() || undefined;
213
216
 
214
217
  corpId =
215
218
  String(
216
219
  await prompter.text({
217
- message: 'Corp ID',
218
- placeholder: 'dingxxxxxxxx',
220
+ message: "Corp ID",
221
+ placeholder: "dingxxxxxxxx",
219
222
  initialValue: resolved.corpId ?? undefined,
220
- })
223
+ }),
221
224
  ).trim() || undefined;
222
225
 
223
226
  agentId =
224
227
  String(
225
228
  await prompter.text({
226
- message: 'Agent ID',
227
- placeholder: '123456789',
229
+ message: "Agent ID",
230
+ placeholder: "123456789",
228
231
  initialValue: resolved.agentId ? String(resolved.agentId) : undefined,
229
- })
232
+ }),
230
233
  ).trim() || undefined;
231
234
  }
232
235
 
233
236
  const wantsCardMode = await prompter.confirm({
234
- message: 'Enable AI interactive card mode? (for streaming AI responses)',
235
- initialValue: resolved.messageType === 'card',
237
+ message: "Enable AI interactive card mode? (for streaming AI responses)",
238
+ initialValue: resolved.messageType === "card",
236
239
  });
237
240
 
238
241
  let cardTemplateId: string | undefined;
239
242
  let cardTemplateKey: string | undefined;
240
- let messageType: 'markdown' | 'card' = 'markdown';
243
+ let messageType: "markdown" | "card" = "markdown";
241
244
 
242
245
  if (wantsCardMode) {
243
246
  await prompter.note(
244
247
  [
245
- 'Create an AI card template in DingTalk Developer Console:',
246
- 'https://open-dev.dingtalk.com/fe/card',
248
+ "Create an AI card template in DingTalk Developer Console:",
249
+ "https://open-dev.dingtalk.com/fe/card",
247
250
  "1. Go to 'My Templates' > 'Create Template'",
248
251
  "2. Select 'AI Card' scenario",
249
- '3. Design your card and publish',
250
- '4. Copy the Template ID (e.g., xxx.schema)',
251
- ].join('\n'),
252
- 'Card Template Setup'
252
+ "3. Design your card and publish",
253
+ "4. Copy the Template ID (e.g., xxx.schema)",
254
+ ].join("\n"),
255
+ "Card Template Setup",
253
256
  );
254
257
 
255
258
  cardTemplateId =
256
259
  String(
257
260
  await prompter.text({
258
- message: 'Card Template ID',
259
- placeholder: 'xxxxx-xxxxx-xxxxx.schema',
261
+ message: "Card Template ID",
262
+ placeholder: "xxxxx-xxxxx-xxxxx.schema",
260
263
  initialValue: resolved.cardTemplateId ?? undefined,
261
- })
264
+ }),
262
265
  ).trim() || undefined;
263
266
 
264
267
  cardTemplateKey =
265
268
  String(
266
269
  await prompter.text({
267
- message: 'Card Template Key (content field name)',
268
- placeholder: 'msgContent',
269
- initialValue: resolved.cardTemplateKey ?? 'msgContent',
270
- })
271
- ).trim() || 'msgContent';
270
+ message: "Card Template Key (content field name)",
271
+ placeholder: "msgContent",
272
+ initialValue: resolved.cardTemplateKey ?? "msgContent",
273
+ }),
274
+ ).trim() || "msgContent";
272
275
 
273
- messageType = 'card';
276
+ messageType = "card";
274
277
  }
275
278
 
276
279
  const dmPolicyValue = await prompter.select({
277
- message: 'Direct message policy',
280
+ message: "Direct message policy",
278
281
  options: [
279
- { label: 'Open - anyone can DM', value: 'open' },
280
- { label: 'Allowlist - only allowed users', value: 'allowlist' },
282
+ { label: "Open - anyone can DM", value: "open" },
283
+ { label: "Allowlist - only allowed users", value: "allowlist" },
281
284
  ],
282
- initialValue: resolved.dmPolicy ?? 'open',
285
+ initialValue: resolved.dmPolicy ?? "open",
283
286
  });
284
287
 
285
288
  let allowFrom: string[] | undefined;
286
- if (dmPolicyValue === 'allowlist') {
289
+ if (dmPolicyValue === "allowlist") {
287
290
  const entry = await prompter.text({
288
- message: 'Allowed user IDs (comma-separated)',
289
- placeholder: 'user1, user2',
291
+ message: "Allowed user IDs (comma-separated)",
292
+ placeholder: "user1, user2",
290
293
  });
291
- const parsed = parseList(String(entry ?? ''));
294
+ const parsed = parseList(String(entry ?? ""));
292
295
  allowFrom = parsed.length > 0 ? parsed : undefined;
293
296
  }
294
297
 
295
298
  const groupPolicyValue = await prompter.select({
296
- message: 'Group message policy',
299
+ message: "Group message policy",
297
300
  options: [
298
- { label: 'Open - any group can use bot', value: 'open' },
299
- { label: 'Allowlist - only allowed groups', value: 'allowlist' },
301
+ { label: "Open - any group can use bot", value: "open" },
302
+ { label: "Allowlist - only allowed groups", value: "allowlist" },
300
303
  ],
301
- initialValue: resolved.groupPolicy ?? 'open',
304
+ initialValue: resolved.groupPolicy ?? "open",
302
305
  });
303
306
 
304
307
  const next = applyAccountConfig({
@@ -310,8 +313,8 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
310
313
  robotCode,
311
314
  corpId,
312
315
  agentId,
313
- dmPolicy: dmPolicyValue as 'open' | 'allowlist',
314
- groupPolicy: groupPolicyValue as 'open' | 'allowlist',
316
+ dmPolicy: dmPolicyValue as "open" | "allowlist",
317
+ groupPolicy: groupPolicyValue as "open" | "allowlist",
315
318
  allowFrom,
316
319
  messageType,
317
320
  cardTemplateId,
@@ -14,7 +14,9 @@ const peerIdMap = new Map<string, string>();
14
14
  * Register an original peer ID, keyed by its lowercased form.
15
15
  */
16
16
  export function registerPeerId(originalId: string): void {
17
- if (!originalId) return;
17
+ if (!originalId) {
18
+ return;
19
+ }
18
20
  peerIdMap.set(originalId.toLowerCase(), originalId);
19
21
  }
20
22
 
@@ -23,7 +25,9 @@ export function registerPeerId(originalId: string): void {
23
25
  * Returns the original if found, otherwise returns the input as-is.
24
26
  */
25
27
  export function resolveOriginalPeerId(id: string): string {
26
- if (!id) return id;
28
+ if (!id) {
29
+ return id;
30
+ }
27
31
  return peerIdMap.get(id.toLowerCase()) || id;
28
32
  }
29
33
 
package/src/runtime.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginRuntime } from 'openclaw/plugin-sdk';
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
2
 
3
3
  let runtime: PluginRuntime | null = null;
4
4
 
@@ -8,7 +8,7 @@ export function setDingTalkRuntime(next: PluginRuntime): void {
8
8
 
9
9
  export function getDingTalkRuntime(): PluginRuntime {
10
10
  if (!runtime) {
11
- throw new Error('DingTalk runtime not initialized');
11
+ throw new Error("DingTalk runtime not initialized");
12
12
  }
13
13
  return runtime;
14
14
  }