@largezhou/ddingtalk 1.4.3 → 2.0.0-beta.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.
- package/index.ts +9 -19
- package/package.json +4 -4
- package/src/accounts.ts +1 -1
- package/src/channel.ts +8 -86
- package/src/monitor.ts +3 -1
- package/src/runtime.ts +1 -1
- package/src/setup-core.ts +170 -0
- package/src/setup-surface.ts +163 -0
- package/src/onboarding.ts +0 -240
package/index.ts
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
3
2
|
import { dingtalkPlugin } from "./src/channel.js";
|
|
4
3
|
import { setDingTalkRuntime } from "./src/runtime.js";
|
|
5
|
-
import { PLUGIN_ID } from "./src/constants.js";
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
register: (api: OpenClawPluginApi) => void;
|
|
13
|
-
} = {
|
|
14
|
-
id: PLUGIN_ID,
|
|
5
|
+
export { dingtalkPlugin } from "./src/channel.js";
|
|
6
|
+
export { setDingTalkRuntime } from "./src/runtime.js";
|
|
7
|
+
|
|
8
|
+
export default defineChannelPluginEntry({
|
|
9
|
+
id: "ddingtalk",
|
|
15
10
|
name: "DingTalk",
|
|
16
11
|
description: "DingTalk (钉钉) enterprise robot channel plugin",
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
api.registerChannel({ plugin: dingtalkPlugin });
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export default plugin;
|
|
12
|
+
plugin: dingtalkPlugin,
|
|
13
|
+
setRuntime: setDingTalkRuntime,
|
|
14
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@largezhou/ddingtalk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-beta.1",
|
|
4
4
|
"description": "OpenClaw DingTalk (钉钉) channel plugin",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
"zod": "^4.0.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@types/node": "^
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
37
|
"dotenv": "^17.3.1",
|
|
38
|
-
"openclaw": "
|
|
38
|
+
"openclaw": ">=2026.3.22",
|
|
39
39
|
"tsx": "^4.6.0",
|
|
40
40
|
"typescript": "^5.3.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"openclaw": "
|
|
43
|
+
"openclaw": ">=2026.3.22"
|
|
44
44
|
},
|
|
45
45
|
"openclaw": {
|
|
46
46
|
"extensions": [
|
package/src/accounts.ts
CHANGED
package/src/channel.ts
CHANGED
|
@@ -3,16 +3,14 @@ import {
|
|
|
3
3
|
DEFAULT_ACCOUNT_ID,
|
|
4
4
|
setAccountEnabledInConfigSection,
|
|
5
5
|
deleteAccountFromConfigSection,
|
|
6
|
-
applyAccountNameToChannelSection,
|
|
7
6
|
formatPairingApproveHint,
|
|
8
|
-
loadWebMedia,
|
|
9
|
-
missingTargetError,
|
|
10
7
|
normalizeAccountId,
|
|
11
8
|
type ChannelPlugin,
|
|
12
|
-
type ChannelStatusIssue,
|
|
13
|
-
type ChannelAccountSnapshot,
|
|
14
9
|
type OpenClawConfig,
|
|
15
|
-
} from "openclaw/plugin-sdk";
|
|
10
|
+
} from "openclaw/plugin-sdk/core";
|
|
11
|
+
import type { ChannelStatusIssue, ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
|
|
12
|
+
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
|
13
|
+
import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
|
|
16
14
|
import path from "path";
|
|
17
15
|
import { getDingTalkRuntime } from "./runtime.js";
|
|
18
16
|
import {
|
|
@@ -24,9 +22,10 @@ import { DingTalkConfigSchema, type DingTalkConfig, type ResolvedDingTalkAccount
|
|
|
24
22
|
import { sendTextMessage, sendImageMessage, sendFileMessage, sendAudioMessage, sendVideoMessage, uploadMedia, probeDingTalkBot, inferMediaType, isGroupTarget } from "./client.js";
|
|
25
23
|
import { logger } from "./logger.js";
|
|
26
24
|
import { monitorDingTalkProvider } from "./monitor.js";
|
|
27
|
-
import { dingtalkOnboardingAdapter } from "./onboarding.js";
|
|
28
25
|
import { PLUGIN_ID } from "./constants.js";
|
|
29
26
|
import { hasFFmpeg, probeMediaBuffer } from "./ffmpeg.js";
|
|
27
|
+
import { dingtalkSetupAdapter } from "./setup-core.js";
|
|
28
|
+
import { dingtalkSetupWizard } from "./setup-surface.js";
|
|
30
29
|
|
|
31
30
|
// ======================= Target Normalization =======================
|
|
32
31
|
|
|
@@ -93,7 +92,7 @@ const meta = {
|
|
|
93
92
|
export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
94
93
|
id: PLUGIN_ID,
|
|
95
94
|
meta,
|
|
96
|
-
|
|
95
|
+
setupWizard: dingtalkSetupWizard,
|
|
97
96
|
capabilities: {
|
|
98
97
|
chatTypes: ["direct", "group"],
|
|
99
98
|
reactions: false,
|
|
@@ -195,84 +194,7 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
195
194
|
},
|
|
196
195
|
},
|
|
197
196
|
|
|
198
|
-
setup:
|
|
199
|
-
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
200
|
-
applyAccountName: ({ cfg, accountId, name }) =>
|
|
201
|
-
applyAccountNameToChannelSection({
|
|
202
|
-
cfg,
|
|
203
|
-
channelKey: PLUGIN_ID,
|
|
204
|
-
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
|
205
|
-
name,
|
|
206
|
-
}),
|
|
207
|
-
validateInput: ({ input }) => {
|
|
208
|
-
const typedInput = input as {
|
|
209
|
-
clientId?: string;
|
|
210
|
-
clientSecret?: string;
|
|
211
|
-
};
|
|
212
|
-
if (!typedInput.clientId) {
|
|
213
|
-
return "DingTalk requires clientId.";
|
|
214
|
-
}
|
|
215
|
-
if (!typedInput.clientSecret) {
|
|
216
|
-
return "DingTalk requires clientSecret.";
|
|
217
|
-
}
|
|
218
|
-
return null;
|
|
219
|
-
},
|
|
220
|
-
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
221
|
-
const typedInput = input as {
|
|
222
|
-
name?: string;
|
|
223
|
-
clientId?: string;
|
|
224
|
-
clientSecret?: string;
|
|
225
|
-
};
|
|
226
|
-
const aid = normalizeAccountId(accountId);
|
|
227
|
-
|
|
228
|
-
// 应用账号名称
|
|
229
|
-
let next = applyAccountNameToChannelSection({
|
|
230
|
-
cfg,
|
|
231
|
-
channelKey: PLUGIN_ID,
|
|
232
|
-
accountId: aid,
|
|
233
|
-
name: typedInput.name,
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
const dingtalkConfig = (next.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
|
|
237
|
-
|
|
238
|
-
// default 账号 → 写顶层(兼容旧版 + 前端面板)
|
|
239
|
-
if (aid === DEFAULT_ACCOUNT_ID) {
|
|
240
|
-
return {
|
|
241
|
-
...next,
|
|
242
|
-
channels: {
|
|
243
|
-
...next.channels,
|
|
244
|
-
[PLUGIN_ID]: {
|
|
245
|
-
...dingtalkConfig,
|
|
246
|
-
enabled: true,
|
|
247
|
-
...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
|
|
248
|
-
...(typedInput.clientSecret ? { clientSecret: typedInput.clientSecret } : {}),
|
|
249
|
-
},
|
|
250
|
-
},
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// 非 default 账号 → 写 accounts[accountId]
|
|
255
|
-
return {
|
|
256
|
-
...next,
|
|
257
|
-
channels: {
|
|
258
|
-
...next.channels,
|
|
259
|
-
[PLUGIN_ID]: {
|
|
260
|
-
...dingtalkConfig,
|
|
261
|
-
enabled: true,
|
|
262
|
-
accounts: {
|
|
263
|
-
...dingtalkConfig.accounts,
|
|
264
|
-
[aid]: {
|
|
265
|
-
...dingtalkConfig.accounts?.[aid],
|
|
266
|
-
enabled: true,
|
|
267
|
-
...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
|
|
268
|
-
...(typedInput.clientSecret ? { clientSecret: typedInput.clientSecret } : {}),
|
|
269
|
-
},
|
|
270
|
-
},
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
|
-
};
|
|
274
|
-
},
|
|
275
|
-
},
|
|
197
|
+
setup: dingtalkSetupAdapter,
|
|
276
198
|
outbound: {
|
|
277
199
|
deliveryMode: "direct",
|
|
278
200
|
chunker: (text, limit) => getDingTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
package/src/monitor.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { DWClient, TOPIC_ROBOT, type DWClientDownStream } from "dingtalk-stream";
|
|
2
|
-
import { recordInboundSession
|
|
2
|
+
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
|
3
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
4
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
3
5
|
import type { DingTalkMessageData, ResolvedDingTalkAccount, DingTalkGroupConfig, AudioContent, VideoContent, FileContent, PictureContent, RichTextContent, RichTextElement, RichTextPictureElement } from "./types.js";
|
|
4
6
|
import { replyViaWebhook, getFileDownloadUrl, downloadFromUrl, sendTextMessage } from "./client.js";
|
|
5
7
|
import { resolveDingTalkAccount } from "./accounts.js";
|
package/src/runtime.ts
CHANGED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applySetupAccountConfigPatch,
|
|
3
|
+
splitSetupEntries,
|
|
4
|
+
DEFAULT_ACCOUNT_ID,
|
|
5
|
+
type OpenClawConfig,
|
|
6
|
+
type WizardPrompter,
|
|
7
|
+
} from "openclaw/plugin-sdk/setup";
|
|
8
|
+
import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/setup";
|
|
9
|
+
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
|
10
|
+
import {
|
|
11
|
+
resolveDefaultDingTalkAccountId,
|
|
12
|
+
resolveDingTalkAccount,
|
|
13
|
+
} from "./accounts.js";
|
|
14
|
+
import { PLUGIN_ID } from "./constants.js";
|
|
15
|
+
|
|
16
|
+
const channel = PLUGIN_ID;
|
|
17
|
+
|
|
18
|
+
export const DINGTALK_CREDENTIAL_HELP_LINES = [
|
|
19
|
+
"1) Log in to DingTalk Open Platform: https://open.dingtalk.com",
|
|
20
|
+
"2) Create an internal enterprise app -> Robot",
|
|
21
|
+
"3) Get AppKey (Client ID) and AppSecret (Client Secret)",
|
|
22
|
+
"4) Enable Stream mode in app configuration",
|
|
23
|
+
`Docs: ${formatDocsLink(`/channels/${PLUGIN_ID}`, PLUGIN_ID)}`,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const DINGTALK_ALLOWFROM_HELP_LINES = [
|
|
27
|
+
"Add DingTalk user IDs that are allowed to interact with the bot.",
|
|
28
|
+
"You can find user IDs in DingTalk admin panel or from bot message logs.",
|
|
29
|
+
"Examples:",
|
|
30
|
+
"- userId123",
|
|
31
|
+
"- manager456",
|
|
32
|
+
"Multiple entries: comma-separated.",
|
|
33
|
+
`Docs: ${formatDocsLink(`/channels/${PLUGIN_ID}`, PLUGIN_ID)}`,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析钉钉 allowFrom 用户 ID
|
|
38
|
+
* 钉钉用户 ID 一般是字母数字组合
|
|
39
|
+
*/
|
|
40
|
+
export function parseDingTalkAllowFromId(raw: string): string | null {
|
|
41
|
+
const stripped = raw
|
|
42
|
+
.trim()
|
|
43
|
+
.replace(new RegExp(`^(${PLUGIN_ID}|dingtalk|dingding):`, "i"), "")
|
|
44
|
+
.replace(/^user:/i, "")
|
|
45
|
+
.trim();
|
|
46
|
+
return /^[a-zA-Z0-9_$+-]+$/i.test(stripped) ? stripped : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 钉钉 allowFrom 条目解析
|
|
51
|
+
* 钉钉没有 API 来通过用户名查找用户 ID,所以直接使用 parseId 结果
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveDingTalkAllowFromEntries(params: {
|
|
54
|
+
entries: string[];
|
|
55
|
+
}) {
|
|
56
|
+
return params.entries.map((entry) => {
|
|
57
|
+
const id = parseDingTalkAllowFromId(entry);
|
|
58
|
+
return { input: entry, resolved: Boolean(id), id };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 交互式 allowFrom 提示
|
|
64
|
+
*/
|
|
65
|
+
export async function promptDingTalkAllowFromForAccount(params: {
|
|
66
|
+
cfg: OpenClawConfig;
|
|
67
|
+
prompter: WizardPrompter;
|
|
68
|
+
accountId?: string;
|
|
69
|
+
}) {
|
|
70
|
+
const accountId =
|
|
71
|
+
params.accountId ?? resolveDefaultDingTalkAccountId(params.cfg);
|
|
72
|
+
const resolved = resolveDingTalkAccount({
|
|
73
|
+
cfg: params.cfg,
|
|
74
|
+
accountId,
|
|
75
|
+
});
|
|
76
|
+
await params.prompter.note(
|
|
77
|
+
DINGTALK_ALLOWFROM_HELP_LINES.join("\n"),
|
|
78
|
+
"DingTalk user id",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// 读取现有 allowFrom
|
|
82
|
+
const existing = resolved.allowFrom ?? [];
|
|
83
|
+
|
|
84
|
+
// 提示输入
|
|
85
|
+
const entry = await params.prompter.text({
|
|
86
|
+
message: "DingTalk allowFrom (user IDs)",
|
|
87
|
+
placeholder: "userId1, userId2",
|
|
88
|
+
initialValue: existing[0] ? String(existing[0]) : undefined,
|
|
89
|
+
validate: (value: string) =>
|
|
90
|
+
String(value ?? "").trim() ? undefined : "Required",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const parts = splitSetupEntries(String(entry));
|
|
94
|
+
const ids = parts
|
|
95
|
+
.map(parseDingTalkAllowFromId)
|
|
96
|
+
.filter(Boolean) as string[];
|
|
97
|
+
const unique = [...new Set([...existing.map(String), ...ids])];
|
|
98
|
+
|
|
99
|
+
return applySetupAccountConfigPatch({
|
|
100
|
+
cfg: params.cfg,
|
|
101
|
+
channelKey: channel,
|
|
102
|
+
accountId,
|
|
103
|
+
patch: { allowFrom: unique },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 检查钉钉账号的凭据状态
|
|
109
|
+
*/
|
|
110
|
+
export function inspectDingTalkSetupAccount(params: {
|
|
111
|
+
cfg: OpenClawConfig;
|
|
112
|
+
accountId: string;
|
|
113
|
+
}) {
|
|
114
|
+
const account = resolveDingTalkAccount(params);
|
|
115
|
+
const hasClientId = Boolean(account.clientId?.trim());
|
|
116
|
+
const hasClientSecret = Boolean(account.clientSecret?.trim());
|
|
117
|
+
return {
|
|
118
|
+
configured: hasClientId && hasClientSecret,
|
|
119
|
+
clientId: account.clientId,
|
|
120
|
+
clientSecret: account.clientSecret,
|
|
121
|
+
tokenSource: account.tokenSource,
|
|
122
|
+
hasClientId,
|
|
123
|
+
hasClientSecret,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 钉钉 ChannelSetupAdapter
|
|
129
|
+
*
|
|
130
|
+
* 钉钉使用 clientId + clientSecret 作为凭据,与 Discord/Telegram 的单 token 不同,
|
|
131
|
+
* 所以不使用 createEnvPatchedAccountSetupAdapter,而是手写适配器来处理两个凭据字段。
|
|
132
|
+
*/
|
|
133
|
+
export const dingtalkSetupAdapter: ChannelSetupAdapter = {
|
|
134
|
+
resolveAccountId: ({ accountId }) => accountId ?? DEFAULT_ACCOUNT_ID,
|
|
135
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
136
|
+
applySetupAccountConfigPatch({
|
|
137
|
+
cfg,
|
|
138
|
+
channelKey: channel,
|
|
139
|
+
accountId,
|
|
140
|
+
patch: { name },
|
|
141
|
+
}),
|
|
142
|
+
validateInput: ({ input }) => {
|
|
143
|
+
const typedInput = input as {
|
|
144
|
+
clientId?: string;
|
|
145
|
+
clientSecret?: string;
|
|
146
|
+
};
|
|
147
|
+
if (!typedInput.clientId && !typedInput.clientSecret) {
|
|
148
|
+
return "DingTalk requires clientId and clientSecret.";
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
},
|
|
152
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
153
|
+
const typedInput = input as {
|
|
154
|
+
name?: string;
|
|
155
|
+
clientId?: string;
|
|
156
|
+
clientSecret?: string;
|
|
157
|
+
};
|
|
158
|
+
return applySetupAccountConfigPatch({
|
|
159
|
+
cfg,
|
|
160
|
+
channelKey: channel,
|
|
161
|
+
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
|
162
|
+
patch: {
|
|
163
|
+
...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
|
|
164
|
+
...(typedInput.clientSecret
|
|
165
|
+
? { clientSecret: typedInput.clientSecret }
|
|
166
|
+
: {}),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAllowFromSection,
|
|
3
|
+
createStandardChannelSetupStatus,
|
|
4
|
+
DEFAULT_ACCOUNT_ID,
|
|
5
|
+
type OpenClawConfig,
|
|
6
|
+
applySetupAccountConfigPatch,
|
|
7
|
+
setSetupChannelEnabled,
|
|
8
|
+
splitSetupEntries,
|
|
9
|
+
} from "openclaw/plugin-sdk/setup";
|
|
10
|
+
import type {
|
|
11
|
+
ChannelSetupDmPolicy,
|
|
12
|
+
ChannelSetupWizard,
|
|
13
|
+
} from "openclaw/plugin-sdk/setup";
|
|
14
|
+
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
|
15
|
+
import {
|
|
16
|
+
listDingTalkAccountIds,
|
|
17
|
+
resolveDingTalkAccount,
|
|
18
|
+
} from "./accounts.js";
|
|
19
|
+
import { PLUGIN_ID } from "./constants.js";
|
|
20
|
+
import {
|
|
21
|
+
DINGTALK_ALLOWFROM_HELP_LINES,
|
|
22
|
+
DINGTALK_CREDENTIAL_HELP_LINES,
|
|
23
|
+
inspectDingTalkSetupAccount,
|
|
24
|
+
parseDingTalkAllowFromId,
|
|
25
|
+
promptDingTalkAllowFromForAccount,
|
|
26
|
+
resolveDingTalkAllowFromEntries,
|
|
27
|
+
} from "./setup-core.js";
|
|
28
|
+
|
|
29
|
+
const channel = PLUGIN_ID;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 钉钉 DM 策略
|
|
33
|
+
*/
|
|
34
|
+
const dmPolicy: ChannelSetupDmPolicy = {
|
|
35
|
+
label: "DingTalk",
|
|
36
|
+
channel,
|
|
37
|
+
policyKey: `channels.${channel}.dmPolicy`,
|
|
38
|
+
allowFromKey: `channels.${channel}.allowFrom`,
|
|
39
|
+
getCurrent: (cfg) =>
|
|
40
|
+
(cfg.channels?.[channel] as { dmPolicy?: "open" | "pairing" | "allowlist" } | undefined)
|
|
41
|
+
?.dmPolicy ?? "pairing",
|
|
42
|
+
setPolicy: (cfg, policy) =>
|
|
43
|
+
applySetupAccountConfigPatch({
|
|
44
|
+
cfg,
|
|
45
|
+
channelKey: channel,
|
|
46
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
47
|
+
patch: { dmPolicy: policy },
|
|
48
|
+
}),
|
|
49
|
+
promptAllowFrom: promptDingTalkAllowFromForAccount,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 钉钉 ChannelSetupWizard — 交互式配置向导
|
|
54
|
+
*
|
|
55
|
+
* 声明式描述了钉钉 Stream 模式机器人的配置流程:
|
|
56
|
+
* 1. 凭据步骤:clientId (AppKey) + clientSecret (AppSecret)
|
|
57
|
+
* 2. AllowFrom:配置允许的用户 ID
|
|
58
|
+
* 3. DM 策略
|
|
59
|
+
*/
|
|
60
|
+
export const dingtalkSetupWizard: ChannelSetupWizard = {
|
|
61
|
+
channel,
|
|
62
|
+
status: createStandardChannelSetupStatus({
|
|
63
|
+
channelLabel: "DingTalk",
|
|
64
|
+
configuredLabel: "configured",
|
|
65
|
+
unconfiguredLabel: "needs credentials",
|
|
66
|
+
configuredHint: "configured",
|
|
67
|
+
unconfiguredHint: "needs AppKey & AppSecret",
|
|
68
|
+
configuredScore: 2,
|
|
69
|
+
unconfiguredScore: 1,
|
|
70
|
+
resolveConfigured: ({ cfg }) =>
|
|
71
|
+
listDingTalkAccountIds(cfg).some((accountId) => {
|
|
72
|
+
const account = inspectDingTalkSetupAccount({ cfg, accountId });
|
|
73
|
+
return account.configured;
|
|
74
|
+
}),
|
|
75
|
+
}),
|
|
76
|
+
|
|
77
|
+
// 钉钉使用两个凭据:clientId + clientSecret
|
|
78
|
+
// ChannelSetupWizardCredential 每个只处理一个密钥,所以分两步
|
|
79
|
+
credentials: [
|
|
80
|
+
{
|
|
81
|
+
inputKey: "token", // 复用 token 字段映射 clientId
|
|
82
|
+
providerHint: channel,
|
|
83
|
+
credentialLabel: "DingTalk AppKey (Client ID)",
|
|
84
|
+
helpTitle: "DingTalk credentials",
|
|
85
|
+
helpLines: DINGTALK_CREDENTIAL_HELP_LINES,
|
|
86
|
+
envPrompt: "DINGTALK_CLIENT_ID detected. Use env var?",
|
|
87
|
+
keepPrompt: "DingTalk AppKey already configured. Keep it?",
|
|
88
|
+
inputPrompt: "Enter DingTalk AppKey (Client ID)",
|
|
89
|
+
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
|
90
|
+
inspect: ({ cfg, accountId }) => {
|
|
91
|
+
const account = inspectDingTalkSetupAccount({ cfg, accountId });
|
|
92
|
+
return {
|
|
93
|
+
accountConfigured: account.configured,
|
|
94
|
+
hasConfiguredValue: account.hasClientId,
|
|
95
|
+
resolvedValue: account.clientId?.trim() || undefined,
|
|
96
|
+
envValue:
|
|
97
|
+
accountId === DEFAULT_ACCOUNT_ID
|
|
98
|
+
? process.env.DINGTALK_CLIENT_ID?.trim() || undefined
|
|
99
|
+
: undefined,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
applySet: async ({ cfg, accountId, resolvedValue }) =>
|
|
103
|
+
applySetupAccountConfigPatch({
|
|
104
|
+
cfg,
|
|
105
|
+
channelKey: channel,
|
|
106
|
+
accountId,
|
|
107
|
+
patch: { clientId: resolvedValue },
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
inputKey: "privateKey", // 复用 privateKey 字段映射 clientSecret
|
|
112
|
+
providerHint: channel,
|
|
113
|
+
credentialLabel: "DingTalk AppSecret (Client Secret)",
|
|
114
|
+
envPrompt: "DINGTALK_CLIENT_SECRET detected. Use env var?",
|
|
115
|
+
keepPrompt: "DingTalk AppSecret already configured. Keep it?",
|
|
116
|
+
inputPrompt: "Enter DingTalk AppSecret (Client Secret)",
|
|
117
|
+
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
|
118
|
+
inspect: ({ cfg, accountId }) => {
|
|
119
|
+
const account = inspectDingTalkSetupAccount({ cfg, accountId });
|
|
120
|
+
return {
|
|
121
|
+
accountConfigured: account.configured,
|
|
122
|
+
hasConfiguredValue: account.hasClientSecret,
|
|
123
|
+
resolvedValue: account.clientSecret?.trim() || undefined,
|
|
124
|
+
envValue:
|
|
125
|
+
accountId === DEFAULT_ACCOUNT_ID
|
|
126
|
+
? process.env.DINGTALK_CLIENT_SECRET?.trim() || undefined
|
|
127
|
+
: undefined,
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
applySet: async ({ cfg, accountId, resolvedValue }) =>
|
|
131
|
+
applySetupAccountConfigPatch({
|
|
132
|
+
cfg,
|
|
133
|
+
channelKey: channel,
|
|
134
|
+
accountId,
|
|
135
|
+
patch: { clientSecret: resolvedValue },
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
|
|
140
|
+
// allowFrom 配置
|
|
141
|
+
allowFrom: createAllowFromSection({
|
|
142
|
+
helpTitle: "DingTalk user id",
|
|
143
|
+
helpLines: DINGTALK_ALLOWFROM_HELP_LINES,
|
|
144
|
+
message: "DingTalk allowFrom (user IDs)",
|
|
145
|
+
placeholder: "userId1, userId2",
|
|
146
|
+
invalidWithoutCredentialNote:
|
|
147
|
+
"Please enter valid DingTalk user IDs (alphanumeric format).",
|
|
148
|
+
parseInputs: splitSetupEntries,
|
|
149
|
+
parseId: parseDingTalkAllowFromId,
|
|
150
|
+
resolveEntries: async ({ entries }) =>
|
|
151
|
+
resolveDingTalkAllowFromEntries({ entries }),
|
|
152
|
+
apply: async ({ cfg, accountId, allowFrom }) =>
|
|
153
|
+
applySetupAccountConfigPatch({
|
|
154
|
+
cfg,
|
|
155
|
+
channelKey: channel,
|
|
156
|
+
accountId,
|
|
157
|
+
patch: { allowFrom },
|
|
158
|
+
}),
|
|
159
|
+
}),
|
|
160
|
+
|
|
161
|
+
dmPolicy,
|
|
162
|
+
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
|
|
163
|
+
};
|
package/src/onboarding.ts
DELETED
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
|
|
2
|
-
import { DEFAULT_ACCOUNT_ID, promptAccountId } from "openclaw/plugin-sdk";
|
|
3
|
-
import type { DingTalkConfig } from "./types.js";
|
|
4
|
-
import {
|
|
5
|
-
listDingTalkAccountIds,
|
|
6
|
-
resolveDefaultDingTalkAccountId,
|
|
7
|
-
resolveDingTalkAccount,
|
|
8
|
-
} from "./accounts.js";
|
|
9
|
-
import { PLUGIN_ID } from "./constants.js";
|
|
10
|
-
|
|
11
|
-
const channel = PLUGIN_ID;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Display DingTalk credentials configuration help
|
|
15
|
-
*/
|
|
16
|
-
async function noteDingTalkCredentialsHelp(prompter: {
|
|
17
|
-
note: (message: string, title?: string) => Promise<void>;
|
|
18
|
-
}): Promise<void> {
|
|
19
|
-
await prompter.note(
|
|
20
|
-
[
|
|
21
|
-
"1) Log in to DingTalk Open Platform: https://open.dingtalk.com",
|
|
22
|
-
"2) Create an internal enterprise app -> Robot",
|
|
23
|
-
"3) Get AppKey (Client ID) and AppSecret (Client Secret)",
|
|
24
|
-
"4) Enable Stream mode in app configuration",
|
|
25
|
-
"Docs: https://open.dingtalk.com/document/",
|
|
26
|
-
].join("\n"),
|
|
27
|
-
"DingTalk bot setup"
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Prompt for DingTalk credentials (clientId + clientSecret)
|
|
33
|
-
*/
|
|
34
|
-
async function promptDingTalkCredentials(prompter: {
|
|
35
|
-
text: (opts: { message: string; validate?: (value: string) => string | undefined }) => Promise<string | symbol>;
|
|
36
|
-
}): Promise<{ clientId: string; clientSecret: string }> {
|
|
37
|
-
const clientId = String(
|
|
38
|
-
await prompter.text({
|
|
39
|
-
message: "Enter DingTalk AppKey (Client ID)",
|
|
40
|
-
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
41
|
-
})
|
|
42
|
-
).trim();
|
|
43
|
-
const clientSecret = String(
|
|
44
|
-
await prompter.text({
|
|
45
|
-
message: "Enter DingTalk AppSecret (Client Secret)",
|
|
46
|
-
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
47
|
-
})
|
|
48
|
-
).trim();
|
|
49
|
-
return { clientId, clientSecret };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** 需要从顶层迁移到 accounts.default 的字段 */
|
|
53
|
-
const ACCOUNT_LEVEL_KEYS = new Set([
|
|
54
|
-
"name",
|
|
55
|
-
"clientId",
|
|
56
|
-
"clientSecret",
|
|
57
|
-
"allowFrom",
|
|
58
|
-
"groupPolicy",
|
|
59
|
-
"groupAllowFrom",
|
|
60
|
-
"groups",
|
|
61
|
-
]);
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* 当添加非 default 账号时,把顶层的账号级字段迁移到 accounts.default 下。
|
|
65
|
-
* 如果 accounts 字典已存在(已经是多账号模式),则不做迁移。
|
|
66
|
-
*/
|
|
67
|
-
function moveTopLevelToDefaultAccount(
|
|
68
|
-
section: DingTalkConfig,
|
|
69
|
-
): DingTalkConfig {
|
|
70
|
-
// 已有 accounts 字典,不需要迁移
|
|
71
|
-
if (section.accounts && Object.keys(section.accounts).length > 0) {
|
|
72
|
-
return section;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const defaultAccount: Record<string, unknown> = {};
|
|
76
|
-
const cleaned: Record<string, unknown> = {};
|
|
77
|
-
|
|
78
|
-
for (const [key, value] of Object.entries(section)) {
|
|
79
|
-
if (key === "accounts" || key === "defaultAccount") {
|
|
80
|
-
cleaned[key] = value;
|
|
81
|
-
} else if (ACCOUNT_LEVEL_KEYS.has(key) && value !== undefined) {
|
|
82
|
-
defaultAccount[key] = value;
|
|
83
|
-
// 不复制到 cleaned,从顶层移除
|
|
84
|
-
} else {
|
|
85
|
-
cleaned[key] = value;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 没有可迁移的字段
|
|
90
|
-
if (Object.keys(defaultAccount).length === 0) {
|
|
91
|
-
return section;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
...cleaned,
|
|
96
|
-
accounts: {
|
|
97
|
-
[DEFAULT_ACCOUNT_ID]: defaultAccount,
|
|
98
|
-
},
|
|
99
|
-
} as DingTalkConfig;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Apply credentials to the config for a given accountId.
|
|
104
|
-
*
|
|
105
|
-
* 策略(与框架层 Discord/Telegram 一致):
|
|
106
|
-
* - default 账号:写顶层(兼容单账号模式)
|
|
107
|
-
* - 非 default 账号:先把顶层账号级字段迁移到 accounts.default,再写 accounts[accountId]
|
|
108
|
-
*/
|
|
109
|
-
function applyCredentials(
|
|
110
|
-
cfg: Record<string, unknown>,
|
|
111
|
-
accountId: string,
|
|
112
|
-
credentials: { clientId: string; clientSecret: string }
|
|
113
|
-
): Record<string, unknown> {
|
|
114
|
-
const dingtalkConfig = ((cfg.channels as Record<string, unknown>)?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
|
|
115
|
-
|
|
116
|
-
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
117
|
-
// default 账号:写顶层
|
|
118
|
-
return {
|
|
119
|
-
...cfg,
|
|
120
|
-
channels: {
|
|
121
|
-
...(cfg.channels as Record<string, unknown>),
|
|
122
|
-
[PLUGIN_ID]: {
|
|
123
|
-
...dingtalkConfig,
|
|
124
|
-
enabled: true,
|
|
125
|
-
clientId: credentials.clientId,
|
|
126
|
-
clientSecret: credentials.clientSecret,
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// 非 default 账号:先迁移顶层到 accounts.default,再写新账号
|
|
133
|
-
const migrated = moveTopLevelToDefaultAccount(dingtalkConfig);
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
...cfg,
|
|
137
|
-
channels: {
|
|
138
|
-
...(cfg.channels as Record<string, unknown>),
|
|
139
|
-
[PLUGIN_ID]: {
|
|
140
|
-
...migrated,
|
|
141
|
-
enabled: true,
|
|
142
|
-
accounts: {
|
|
143
|
-
...migrated.accounts,
|
|
144
|
-
[accountId]: {
|
|
145
|
-
...migrated.accounts?.[accountId],
|
|
146
|
-
enabled: true,
|
|
147
|
-
clientId: credentials.clientId,
|
|
148
|
-
clientSecret: credentials.clientSecret,
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* DingTalk Onboarding Adapter(支持多账号)
|
|
158
|
-
*/
|
|
159
|
-
export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
160
|
-
channel,
|
|
161
|
-
getStatus: async ({ cfg }) => {
|
|
162
|
-
const configured = listDingTalkAccountIds(cfg).some((accountId) => {
|
|
163
|
-
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
164
|
-
return Boolean(account.clientId?.trim() && account.clientSecret?.trim());
|
|
165
|
-
});
|
|
166
|
-
return {
|
|
167
|
-
channel,
|
|
168
|
-
configured,
|
|
169
|
-
statusLines: [`DingTalk: ${configured ? "configured" : "needs credentials"}`],
|
|
170
|
-
selectionHint: configured ? "configured" : "needs AppKey/AppSecret",
|
|
171
|
-
quickstartScore: configured ? 1 : 5,
|
|
172
|
-
};
|
|
173
|
-
},
|
|
174
|
-
configure: async ({
|
|
175
|
-
cfg,
|
|
176
|
-
prompter,
|
|
177
|
-
shouldPromptAccountIds,
|
|
178
|
-
accountOverrides,
|
|
179
|
-
}) => {
|
|
180
|
-
let next = cfg;
|
|
181
|
-
|
|
182
|
-
// 1. 解析 accountId:支持多账号选择 / 添加新账号
|
|
183
|
-
const defaultAccountId = resolveDefaultDingTalkAccountId(cfg);
|
|
184
|
-
const override = accountOverrides?.[PLUGIN_ID]?.trim();
|
|
185
|
-
let accountId = override ?? defaultAccountId;
|
|
186
|
-
|
|
187
|
-
if (shouldPromptAccountIds && !override) {
|
|
188
|
-
accountId = await promptAccountId({
|
|
189
|
-
cfg,
|
|
190
|
-
prompter,
|
|
191
|
-
label: "DingTalk",
|
|
192
|
-
currentId: accountId,
|
|
193
|
-
listAccountIds: listDingTalkAccountIds,
|
|
194
|
-
defaultAccountId,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// 2. 检查该账号自身是否已配置凭据(不继承顶层,避免新账号误判为已配置)
|
|
199
|
-
const accountConfigured = (() => {
|
|
200
|
-
const dingtalkSection = (next.channels as Record<string, unknown>)?.[PLUGIN_ID] as DingTalkConfig | undefined;
|
|
201
|
-
if (!dingtalkSection) return false;
|
|
202
|
-
// 检查 accounts[accountId] 自身
|
|
203
|
-
const acct = dingtalkSection.accounts?.[accountId];
|
|
204
|
-
if (acct?.clientId?.trim() && acct?.clientSecret?.trim()) return true;
|
|
205
|
-
// default 账号额外兼容顶层旧配置(手动编辑或旧版迁移)
|
|
206
|
-
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
207
|
-
return Boolean(dingtalkSection.clientId?.trim() && dingtalkSection.clientSecret?.trim());
|
|
208
|
-
}
|
|
209
|
-
return false;
|
|
210
|
-
})();
|
|
211
|
-
|
|
212
|
-
// 3. 凭据输入
|
|
213
|
-
if (!accountConfigured) {
|
|
214
|
-
await noteDingTalkCredentialsHelp(prompter);
|
|
215
|
-
const credentials = await promptDingTalkCredentials(prompter);
|
|
216
|
-
next = applyCredentials(next, accountId, credentials) as typeof next;
|
|
217
|
-
} else {
|
|
218
|
-
const keep = await prompter.confirm({
|
|
219
|
-
message: "DingTalk credentials already configured. Keep them?",
|
|
220
|
-
initialValue: true,
|
|
221
|
-
});
|
|
222
|
-
if (!keep) {
|
|
223
|
-
const credentials = await promptDingTalkCredentials(prompter);
|
|
224
|
-
next = applyCredentials(next, accountId, credentials) as typeof next;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return { cfg: next, accountId };
|
|
229
|
-
},
|
|
230
|
-
disable: (cfg) => {
|
|
231
|
-
const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
|
|
232
|
-
return {
|
|
233
|
-
...cfg,
|
|
234
|
-
channels: {
|
|
235
|
-
...cfg.channels,
|
|
236
|
-
[PLUGIN_ID]: { ...dingtalkConfig, enabled: false },
|
|
237
|
-
},
|
|
238
|
-
};
|
|
239
|
-
},
|
|
240
|
-
};
|