@largezhou/ddingtalk 1.5.0-beta.1 → 2.0.0-beta.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.
package/index.ts CHANGED
@@ -1,24 +1,14 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "./src/compat.js";
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
- const plugin: {
8
- id: string;
9
- name: string;
10
- description: string;
11
- configSchema: ReturnType<typeof emptyPluginConfigSchema>;
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
- configSchema: emptyPluginConfigSchema(),
18
- register(api: OpenClawPluginApi) {
19
- setDingTalkRuntime(api.runtime);
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": "1.5.0-beta.1",
3
+ "version": "2.0.0-beta.2",
4
4
  "description": "OpenClaw DingTalk (钉钉) channel plugin",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -30,17 +30,18 @@
30
30
  "license": "MIT",
31
31
  "dependencies": {
32
32
  "dingtalk-stream": "^2.1.4",
33
- "zod": "^4.0.0"
33
+ "zod": "^4.0.0",
34
+ "openclaw": ">=2026.3.22"
34
35
  },
35
36
  "devDependencies": {
36
- "@types/node": "^20.10.0",
37
+ "@types/node": "^22.0.0",
37
38
  "dotenv": "^17.3.1",
38
- "openclaw": "*",
39
+ "openclaw": ">=2026.3.22",
39
40
  "tsx": "^4.6.0",
40
41
  "typescript": "^5.3.0"
41
42
  },
42
43
  "peerDependencies": {
43
- "openclaw": "*"
44
+ "openclaw": ">=2026.3.22"
44
45
  },
45
46
  "openclaw": {
46
47
  "extensions": [
package/src/accounts.ts CHANGED
@@ -1,10 +1,8 @@
1
- import {
2
- type OpenClawConfig,
3
- } from "openclaw/plugin-sdk";
4
1
  import {
5
2
  DEFAULT_ACCOUNT_ID,
6
3
  normalizeAccountId,
7
- } from "./compat.js";
4
+ type OpenClawConfig,
5
+ } from "openclaw/plugin-sdk/core";
8
6
  import type {
9
7
  DingTalkConfig,
10
8
  DingTalkAccountConfig,
package/src/channel.ts CHANGED
@@ -1,20 +1,16 @@
1
- import {
2
- type ChannelPlugin,
3
- type ChannelStatusIssue,
4
- type ChannelAccountSnapshot,
5
- type OpenClawConfig,
6
- } from "openclaw/plugin-sdk";
7
1
  import {
8
2
  buildChannelConfigSchema,
9
3
  DEFAULT_ACCOUNT_ID,
10
4
  setAccountEnabledInConfigSection,
11
5
  deleteAccountFromConfigSection,
12
- applyAccountNameToChannelSection,
13
6
  formatPairingApproveHint,
14
- loadWebMedia,
15
- missingTargetError,
16
7
  normalizeAccountId,
17
- } from "./compat.js";
8
+ type ChannelPlugin,
9
+ type OpenClawConfig,
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";
18
14
  import path from "path";
19
15
  import { getDingTalkRuntime } from "./runtime.js";
20
16
  import {
@@ -26,9 +22,10 @@ import { DingTalkConfigSchema, type DingTalkConfig, type ResolvedDingTalkAccount
26
22
  import { sendTextMessage, sendImageMessage, sendFileMessage, sendAudioMessage, sendVideoMessage, uploadMedia, probeDingTalkBot, inferMediaType, isGroupTarget } from "./client.js";
27
23
  import { logger } from "./logger.js";
28
24
  import { monitorDingTalkProvider } from "./monitor.js";
29
- import { dingtalkOnboardingAdapter } from "./onboarding.js";
30
25
  import { PLUGIN_ID } from "./constants.js";
31
26
  import { hasFFmpeg, probeMediaBuffer } from "./ffmpeg.js";
27
+ import { dingtalkSetupAdapter } from "./setup-core.js";
28
+ import { dingtalkSetupWizard } from "./setup-surface.js";
32
29
 
33
30
  // ======================= Target Normalization =======================
34
31
 
@@ -95,7 +92,7 @@ const meta = {
95
92
  export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
96
93
  id: PLUGIN_ID,
97
94
  meta,
98
- onboarding: dingtalkOnboardingAdapter,
95
+ setupWizard: dingtalkSetupWizard,
99
96
  capabilities: {
100
97
  chatTypes: ["direct", "group"],
101
98
  reactions: false,
@@ -197,84 +194,7 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
197
194
  },
198
195
  },
199
196
 
200
- setup: {
201
- resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
202
- applyAccountName: ({ cfg, accountId, name }) =>
203
- applyAccountNameToChannelSection({
204
- cfg,
205
- channelKey: PLUGIN_ID,
206
- accountId: accountId ?? DEFAULT_ACCOUNT_ID,
207
- name,
208
- }),
209
- validateInput: ({ input }) => {
210
- const typedInput = input as {
211
- clientId?: string;
212
- clientSecret?: string;
213
- };
214
- if (!typedInput.clientId) {
215
- return "DingTalk requires clientId.";
216
- }
217
- if (!typedInput.clientSecret) {
218
- return "DingTalk requires clientSecret.";
219
- }
220
- return null;
221
- },
222
- applyAccountConfig: ({ cfg, accountId, input }) => {
223
- const typedInput = input as {
224
- name?: string;
225
- clientId?: string;
226
- clientSecret?: string;
227
- };
228
- const aid = normalizeAccountId(accountId);
229
-
230
- // 应用账号名称
231
- let next = applyAccountNameToChannelSection({
232
- cfg,
233
- channelKey: PLUGIN_ID,
234
- accountId: aid,
235
- name: typedInput.name,
236
- });
237
-
238
- const dingtalkConfig = (next.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
239
-
240
- // default 账号 → 写顶层(兼容旧版 + 前端面板)
241
- if (aid === DEFAULT_ACCOUNT_ID) {
242
- return {
243
- ...next,
244
- channels: {
245
- ...next.channels,
246
- [PLUGIN_ID]: {
247
- ...dingtalkConfig,
248
- enabled: true,
249
- ...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
250
- ...(typedInput.clientSecret ? { clientSecret: typedInput.clientSecret } : {}),
251
- },
252
- },
253
- };
254
- }
255
-
256
- // 非 default 账号 → 写 accounts[accountId]
257
- return {
258
- ...next,
259
- channels: {
260
- ...next.channels,
261
- [PLUGIN_ID]: {
262
- ...dingtalkConfig,
263
- enabled: true,
264
- accounts: {
265
- ...dingtalkConfig.accounts,
266
- [aid]: {
267
- ...dingtalkConfig.accounts?.[aid],
268
- enabled: true,
269
- ...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
270
- ...(typedInput.clientSecret ? { clientSecret: typedInput.clientSecret } : {}),
271
- },
272
- },
273
- },
274
- },
275
- };
276
- },
277
- },
197
+ setup: dingtalkSetupAdapter,
278
198
  outbound: {
279
199
  deliveryMode: "direct",
280
200
  chunker: (text, limit) => getDingTalkRuntime().channel.text.chunkMarkdownText(text, limit),
package/src/monitor.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { DWClient, TOPIC_ROBOT, type DWClientDownStream } from "dingtalk-stream";
2
- import { type OpenClawConfig, type RuntimeEnv } from "openclaw/plugin-sdk";
3
- import { recordInboundSession } from "./compat.js";
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";
4
5
  import type { DingTalkMessageData, ResolvedDingTalkAccount, DingTalkGroupConfig, AudioContent, VideoContent, FileContent, PictureContent, RichTextContent, RichTextElement, RichTextPictureElement } from "./types.js";
5
6
  import { replyViaWebhook, getFileDownloadUrl, downloadFromUrl, sendTextMessage } from "./client.js";
6
7
  import { resolveDingTalkAccount } from "./accounts.js";
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/core";
2
2
 
3
3
  let runtime: PluginRuntime | null = null;
4
4
 
@@ -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/compat.ts DELETED
@@ -1,258 +0,0 @@
1
- /**
2
- * 兼容层:处理不同 OpenClaw 版本的 API 差异
3
- *
4
- * 策略:自定义精确的类型接口契约,运行时一次性解析,导出强类型符号。
5
- * 调用方享受完整的 TypeScript 静态检查,不会出现 any 类型污染。
6
- *
7
- * 版本差异说明:
8
- * - v2026.2.x ~ v2026.3.11:所有符号从 "openclaw/plugin-sdk" 主入口导出
9
- * - v2026.3.22+:运行时符号分散到子模块路径,类型仍从主入口导出
10
- */
11
-
12
- import { createRequire } from "node:module";
13
- import type { ZodTypeAny } from "zod";
14
-
15
- // ============================================================================
16
- // 类型契约:精确定义我们需要的每个符号的类型签名
17
- // 这些类型基于 openclaw 源码中的 .d.ts 文件提取,保证与真实接口一致
18
- // ============================================================================
19
-
20
- /** @see openclaw/dist/plugin-sdk/channels/plugins/types.plugin.d.ts */
21
- export type ChannelConfigUiHint = {
22
- label?: string;
23
- help?: string;
24
- tags?: string[];
25
- advanced?: boolean;
26
- sensitive?: boolean;
27
- placeholder?: string;
28
- itemTemplate?: unknown;
29
- };
30
-
31
- export type ChannelConfigSchema = {
32
- schema: Record<string, unknown>;
33
- uiHints?: Record<string, ChannelConfigUiHint>;
34
- };
35
-
36
- /** OpenClaw 配置的最小类型约束(避免直接依赖 OpenClawConfig 的子路径) */
37
- type CfgLike = { channels?: Record<string, unknown>;[key: string]: unknown };
38
-
39
- // 签名类型定义
40
- type BuildChannelConfigSchemaFn = (schema: ZodTypeAny) => ChannelConfigSchema;
41
- type NormalizeAccountIdFn = (value: string | undefined | null) => string;
42
- type SetAccountEnabledFn = (params: {
43
- cfg: CfgLike; sectionKey: string; accountId: string; enabled: boolean; allowTopLevel?: boolean;
44
- }) => CfgLike;
45
- type DeleteAccountFn = (params: {
46
- cfg: CfgLike; sectionKey: string; accountId: string; clearBaseFields?: string[];
47
- }) => CfgLike;
48
- type ApplyAccountNameFn = (params: {
49
- cfg: CfgLike; channelKey: string; accountId: string; name?: string; alwaysUseAccounts?: boolean;
50
- }) => CfgLike;
51
- type FormatPairingApproveHintFn = (channelId: string) => string;
52
-
53
- /** @see openclaw/dist/plugin-sdk/web/media.d.ts */
54
- export type WebMediaResult = {
55
- buffer: Buffer;
56
- contentType?: string;
57
- kind: string;
58
- fileName?: string;
59
- };
60
- type LoadWebMediaFn = (
61
- mediaUrl: string,
62
- maxBytesOrOptions?: number | Record<string, unknown>,
63
- options?: Record<string, unknown>,
64
- ) => Promise<WebMediaResult>;
65
-
66
- type MissingTargetErrorFn = (provider: string, hint?: string) => Error;
67
-
68
- /** @see openclaw/dist/plugin-sdk/channels/session.d.ts */
69
- type RecordInboundSessionFn = (params: {
70
- storePath: string;
71
- sessionKey: string;
72
- ctx: Record<string, unknown>;
73
- groupResolution?: unknown;
74
- createIfMissing?: boolean;
75
- updateLastRoute?: {
76
- sessionKey: string;
77
- channel: string;
78
- to: string;
79
- accountId?: string;
80
- threadId?: string | number;
81
- };
82
- onRecordError: (err: unknown) => void;
83
- }) => Promise<void>;
84
-
85
- /** @see openclaw/dist/plugin-sdk/plugin-sdk/onboarding.d.ts */
86
- type PromptAccountIdFn = (params: {
87
- cfg: CfgLike;
88
- prompter: unknown;
89
- label: string;
90
- currentId?: string;
91
- listAccountIds: (cfg: CfgLike) => string[];
92
- defaultAccountId: string;
93
- }) => Promise<string>;
94
-
95
- type EmptyPluginConfigSchemaFn = () => unknown;
96
-
97
- // ============================================================================
98
- // 运行时解析引擎
99
- // ============================================================================
100
-
101
- const require = createRequire(import.meta.url);
102
-
103
- /**
104
- * 从多个模块路径中尝试加载指定符号。
105
- * 模块路径按优先级排列,先新版子路径,后旧版主入口。
106
- */
107
- function resolve<T>(symbolName: string, modulePaths: string[], fallback: T): T {
108
- for (const modulePath of modulePaths) {
109
- try {
110
- const mod = require(modulePath);
111
- if (symbolName in mod && typeof mod[symbolName] !== "undefined") {
112
- return mod[symbolName] as T;
113
- }
114
- } catch {
115
- // 路径不存在,继续
116
- }
117
- }
118
- return fallback;
119
- }
120
-
121
- // ============================================================================
122
- // 一次性解析所有符号(模块加载时执行,后续调用零开销)
123
- // ============================================================================
124
-
125
- // --- buildChannelConfigSchema ---
126
- export const buildChannelConfigSchema: BuildChannelConfigSchemaFn = resolve(
127
- "buildChannelConfigSchema",
128
- [
129
- "openclaw/plugin-sdk/channel-config-schema",
130
- "openclaw/plugin-sdk/channel-plugin-common",
131
- "openclaw/plugin-sdk/core",
132
- "openclaw/plugin-sdk",
133
- ],
134
- ((_schema: ZodTypeAny): ChannelConfigSchema => ({
135
- schema: { type: "object", additionalProperties: true },
136
- })),
137
- );
138
-
139
- // --- DEFAULT_ACCOUNT_ID ---
140
- export const DEFAULT_ACCOUNT_ID: string = resolve(
141
- "DEFAULT_ACCOUNT_ID",
142
- [
143
- "openclaw/plugin-sdk/channel-plugin-common",
144
- "openclaw/plugin-sdk/core",
145
- "openclaw/plugin-sdk",
146
- ],
147
- "default",
148
- );
149
-
150
- // --- normalizeAccountId ---
151
- export const normalizeAccountId: NormalizeAccountIdFn = resolve(
152
- "normalizeAccountId",
153
- [
154
- "openclaw/plugin-sdk/channel-plugin-common",
155
- "openclaw/plugin-sdk/core",
156
- "openclaw/plugin-sdk",
157
- ],
158
- ((value?: string | null) => value?.trim() || "default"),
159
- );
160
-
161
- // --- setAccountEnabledInConfigSection ---
162
- export const setAccountEnabledInConfigSection: SetAccountEnabledFn = resolve(
163
- "setAccountEnabledInConfigSection",
164
- [
165
- "openclaw/plugin-sdk/channel-plugin-common",
166
- "openclaw/plugin-sdk/core",
167
- "openclaw/plugin-sdk",
168
- ],
169
- ((params) => params.cfg),
170
- );
171
-
172
- // --- deleteAccountFromConfigSection ---
173
- export const deleteAccountFromConfigSection: DeleteAccountFn = resolve(
174
- "deleteAccountFromConfigSection",
175
- [
176
- "openclaw/plugin-sdk/channel-plugin-common",
177
- "openclaw/plugin-sdk/core",
178
- "openclaw/plugin-sdk",
179
- ],
180
- ((params) => params.cfg),
181
- );
182
-
183
- // --- applyAccountNameToChannelSection ---
184
- export const applyAccountNameToChannelSection: ApplyAccountNameFn = resolve(
185
- "applyAccountNameToChannelSection",
186
- [
187
- "openclaw/plugin-sdk/channel-plugin-common",
188
- "openclaw/plugin-sdk/core",
189
- "openclaw/plugin-sdk",
190
- ],
191
- ((params) => params.cfg),
192
- );
193
-
194
- // --- formatPairingApproveHint ---
195
- export const formatPairingApproveHint: FormatPairingApproveHintFn = resolve(
196
- "formatPairingApproveHint",
197
- [
198
- "openclaw/plugin-sdk/channel-plugin-common",
199
- "openclaw/plugin-sdk",
200
- ],
201
- ((channelId: string) => `Approve pairing for ${channelId}`),
202
- );
203
-
204
- // --- loadWebMedia ---
205
- export const loadWebMedia: LoadWebMediaFn = resolve(
206
- "loadWebMedia",
207
- [
208
- "openclaw/plugin-sdk/web-media",
209
- "openclaw/plugin-sdk/msteams",
210
- "openclaw/plugin-sdk",
211
- ],
212
- (async (_url: string) => {
213
- throw new Error("loadWebMedia is not available in this OpenClaw version");
214
- }),
215
- );
216
-
217
- // --- missingTargetError ---
218
- export const missingTargetError: MissingTargetErrorFn = resolve(
219
- "missingTargetError",
220
- [
221
- "openclaw/plugin-sdk/channel-feedback",
222
- "openclaw/plugin-sdk/googlechat",
223
- "openclaw/plugin-sdk",
224
- ],
225
- ((provider: string, hint?: string) =>
226
- new Error(`Missing target for ${provider}${hint ? `. Expected: ${hint}` : ""}`)),
227
- );
228
-
229
- // --- recordInboundSession ---
230
- export const recordInboundSession: RecordInboundSessionFn = resolve(
231
- "recordInboundSession",
232
- [
233
- "openclaw/plugin-sdk/conversation-runtime",
234
- "openclaw/plugin-sdk",
235
- ],
236
- (async () => { /* no-op */ }),
237
- );
238
-
239
- // --- promptAccountId ---
240
- export const promptAccountId: PromptAccountIdFn = resolve(
241
- "promptAccountId",
242
- [
243
- "openclaw/plugin-sdk/matrix",
244
- "openclaw/plugin-sdk",
245
- ],
246
- (async (params) => params.currentId || params.defaultAccountId || "default"),
247
- );
248
-
249
- // --- emptyPluginConfigSchema ---
250
- export const emptyPluginConfigSchema: EmptyPluginConfigSchemaFn = resolve(
251
- "emptyPluginConfigSchema",
252
- [
253
- "openclaw/plugin-sdk",
254
- "openclaw/plugin-sdk/core",
255
- "openclaw/plugin-sdk/channel-plugin-common",
256
- ],
257
- (() => ({ schema: {} })),
258
- );
package/src/onboarding.ts DELETED
@@ -1,240 +0,0 @@
1
- import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, promptAccountId } from "./compat.js";
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
- };