@openclaw-china/shared 0.1.31 → 0.1.32
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/package.json +4 -1
- package/src/asr/README.md +100 -100
- package/src/asr/errors.ts +61 -61
- package/src/asr/index.ts +11 -11
- package/src/asr/tencent-flash.ts +165 -165
- package/src/cli/china-setup.ts +783 -0
- package/src/cli/index.ts +1 -0
- package/src/cron/index.ts +115 -115
- package/src/file/file-utils.test.ts +141 -141
- package/src/file/file-utils.ts +284 -284
- package/src/file/index.ts +10 -10
- package/src/http/client.ts +141 -141
- package/src/http/index.ts +2 -2
- package/src/http/retry.ts +110 -110
- package/src/index.ts +10 -9
- package/src/logger/index.ts +1 -1
- package/src/logger/logger.ts +51 -51
- package/src/media/index.ts +65 -65
- package/src/media/media-io.ts +732 -732
- package/src/media/media-parser.ts +722 -722
- package/src/policy/dm-policy.ts +82 -82
- package/src/policy/group-policy.ts +93 -93
- package/src/policy/index.ts +2 -2
- package/src/types/common.ts +24 -24
- package/tsconfig.json +8 -8
- package/vitest.config.ts +8 -8
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cancel as clackCancel,
|
|
3
|
+
confirm as clackConfirm,
|
|
4
|
+
intro as clackIntro,
|
|
5
|
+
isCancel,
|
|
6
|
+
note as clackNote,
|
|
7
|
+
outro as clackOutro,
|
|
8
|
+
select as clackSelect,
|
|
9
|
+
text as clackText,
|
|
10
|
+
} from "@clack/prompts";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
15
|
+
|
|
16
|
+
type LoggerLike = {
|
|
17
|
+
info?: (message: string) => void;
|
|
18
|
+
warn?: (message: string) => void;
|
|
19
|
+
error?: (message: string) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type CommandLike = {
|
|
23
|
+
command: (name: string) => CommandLike;
|
|
24
|
+
description: (text: string) => CommandLike;
|
|
25
|
+
action: (handler: () => void | Promise<void>) => CommandLike;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ChinaCliContext = {
|
|
29
|
+
program: unknown;
|
|
30
|
+
config?: unknown;
|
|
31
|
+
logger?: LoggerLike;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type RegisterCliLike = (
|
|
35
|
+
registrar: (ctx: ChinaCliContext) => void | Promise<void>,
|
|
36
|
+
opts?: { commands?: string[] }
|
|
37
|
+
) => void;
|
|
38
|
+
|
|
39
|
+
type WriteConfigLike = (cfg: ConfigRoot) => Promise<void>;
|
|
40
|
+
|
|
41
|
+
type ApiLike = {
|
|
42
|
+
registerCli?: RegisterCliLike;
|
|
43
|
+
runtime?: unknown;
|
|
44
|
+
logger?: LoggerLike;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type ConfigRecord = Record<string, unknown>;
|
|
48
|
+
|
|
49
|
+
type ConfigRoot = {
|
|
50
|
+
channels?: ConfigRecord;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ChannelId = "dingtalk" | "feishu-china" | "wecom" | "wecom-app" | "qqbot";
|
|
55
|
+
|
|
56
|
+
export type RegisterChinaSetupCliOptions = {
|
|
57
|
+
channels?: readonly ChannelId[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type Option<T extends string> = {
|
|
61
|
+
key?: string;
|
|
62
|
+
value: T;
|
|
63
|
+
label: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const PROJECT_REPO = "https://github.com/BytePioneer-AI/moltbot-china";
|
|
67
|
+
const GUIDES_BASE = "https://github.com/BytePioneer-AI/openclaw-china/tree/main/doc/guides";
|
|
68
|
+
const OPENCLAW_HOME = join(homedir(), ".openclaw");
|
|
69
|
+
const DEFAULT_PLUGIN_PATH = join(OPENCLAW_HOME, "extensions");
|
|
70
|
+
const LEGACY_PLUGIN_PATH = join(OPENCLAW_HOME, "plugins");
|
|
71
|
+
const CONFIG_FILE_PATH = join(OPENCLAW_HOME, "openclaw.json");
|
|
72
|
+
const ANSI_RESET = "\u001b[0m";
|
|
73
|
+
const ANSI_LINK = "\u001b[1;4;96m";
|
|
74
|
+
const ANSI_BORDER = "\u001b[92m";
|
|
75
|
+
const CHANNEL_ORDER: readonly ChannelId[] = [
|
|
76
|
+
"dingtalk",
|
|
77
|
+
"qqbot",
|
|
78
|
+
"wecom",
|
|
79
|
+
"wecom-app",
|
|
80
|
+
"feishu-china",
|
|
81
|
+
];
|
|
82
|
+
const CHANNEL_DISPLAY_LABELS: Record<ChannelId, string> = {
|
|
83
|
+
dingtalk: "DingTalk(钉钉)",
|
|
84
|
+
"feishu-china": "Feishu(飞书)",
|
|
85
|
+
wecom: "WeCom(企业微信-智能机器人)",
|
|
86
|
+
"wecom-app": "WeCom App(自建应用-可接入微信)",
|
|
87
|
+
qqbot: "QQBot(QQ 机器人)",
|
|
88
|
+
};
|
|
89
|
+
const CHANNEL_GUIDE_LINKS: Record<ChannelId, string> = {
|
|
90
|
+
dingtalk: `${GUIDES_BASE}/dingtalk/configuration.md`,
|
|
91
|
+
"feishu-china": "https://github.com/BytePioneer-AI/openclaw-china/blob/main/README.md",
|
|
92
|
+
wecom: `${GUIDES_BASE}/wecom/configuration.md`,
|
|
93
|
+
"wecom-app": `${GUIDES_BASE}/wecom-app/configuration.md`,
|
|
94
|
+
qqbot: `${GUIDES_BASE}/qqbot/configuration.md`,
|
|
95
|
+
};
|
|
96
|
+
const CHINA_CLI_STATE_KEY = Symbol.for("@openclaw-china/china-cli-state");
|
|
97
|
+
|
|
98
|
+
type ChinaCliState = {
|
|
99
|
+
channels: Set<ChannelId>;
|
|
100
|
+
cliRegistered: boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
class PromptCancelledError extends Error {
|
|
104
|
+
constructor() {
|
|
105
|
+
super("prompt-cancelled");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isChannelId(value: unknown): value is ChannelId {
|
|
110
|
+
return typeof value === "string" && CHANNEL_ORDER.includes(value as ChannelId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getChinaCliState(): ChinaCliState {
|
|
114
|
+
const root = globalThis as Record<PropertyKey, unknown>;
|
|
115
|
+
const cached = root[CHINA_CLI_STATE_KEY];
|
|
116
|
+
|
|
117
|
+
if (isRecord(cached)) {
|
|
118
|
+
const channels = cached.channels;
|
|
119
|
+
const cliRegistered = cached.cliRegistered;
|
|
120
|
+
if (channels instanceof Set && typeof cliRegistered === "boolean") {
|
|
121
|
+
return {
|
|
122
|
+
channels: channels as Set<ChannelId>,
|
|
123
|
+
cliRegistered,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const created: ChinaCliState = {
|
|
129
|
+
channels: new Set<ChannelId>(),
|
|
130
|
+
cliRegistered: false,
|
|
131
|
+
};
|
|
132
|
+
root[CHINA_CLI_STATE_KEY] = created;
|
|
133
|
+
return created;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeChannels(channels?: readonly ChannelId[]): ChannelId[] {
|
|
137
|
+
const selected = channels && channels.length > 0 ? channels : CHANNEL_ORDER;
|
|
138
|
+
const unique = new Set<ChannelId>();
|
|
139
|
+
for (const channelId of selected) {
|
|
140
|
+
if (isChannelId(channelId)) {
|
|
141
|
+
unique.add(channelId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return CHANNEL_ORDER.filter((channelId) => unique.has(channelId));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getInstalledChannels(state: ChinaCliState): ChannelId[] {
|
|
148
|
+
return CHANNEL_ORDER.filter((channelId) => state.channels.has(channelId));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function guardCancel<T>(value: T | symbol): T {
|
|
152
|
+
if (isCancel(value)) {
|
|
153
|
+
clackCancel("已取消配置。");
|
|
154
|
+
throw new PromptCancelledError();
|
|
155
|
+
}
|
|
156
|
+
return value as T;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function warn(text: string): void {
|
|
160
|
+
output.write(`\n[warn] ${text}\n`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function section(title: string): void {
|
|
164
|
+
output.write(`\n${title}\n`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolvePluginPath(): string {
|
|
168
|
+
if (existsSync(DEFAULT_PLUGIN_PATH)) {
|
|
169
|
+
return DEFAULT_PLUGIN_PATH;
|
|
170
|
+
}
|
|
171
|
+
if (existsSync(LEGACY_PLUGIN_PATH)) {
|
|
172
|
+
return LEGACY_PLUGIN_PATH;
|
|
173
|
+
}
|
|
174
|
+
return DEFAULT_PLUGIN_PATH;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renderReadyMessage(): string {
|
|
178
|
+
return [
|
|
179
|
+
`${ANSI_BORDER}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${ANSI_RESET}`,
|
|
180
|
+
" OpenClaw China Channels 已就绪!",
|
|
181
|
+
`${ANSI_BORDER}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${ANSI_RESET}`,
|
|
182
|
+
"",
|
|
183
|
+
"插件路径:",
|
|
184
|
+
` ${resolvePluginPath()}`,
|
|
185
|
+
"",
|
|
186
|
+
"配置文件:",
|
|
187
|
+
` ${CONFIG_FILE_PATH}`,
|
|
188
|
+
"",
|
|
189
|
+
"更新插件:",
|
|
190
|
+
" openclaw plugins update <plugin-id>",
|
|
191
|
+
"",
|
|
192
|
+
"项目仓库:",
|
|
193
|
+
` ${ANSI_LINK}${PROJECT_REPO}${ANSI_RESET}`,
|
|
194
|
+
"",
|
|
195
|
+
"⭐ 如果这个项目对你有帮助,请给我们一个 Star!⭐",
|
|
196
|
+
"",
|
|
197
|
+
"下一步:",
|
|
198
|
+
" openclaw gateway --port 18789 --verbose",
|
|
199
|
+
"",
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function showReadyMessage(): void {
|
|
204
|
+
output.write(`\n${renderReadyMessage()}\n`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function showGuideLink(channelId: ChannelId): void {
|
|
208
|
+
const url = CHANNEL_GUIDE_LINKS[channelId];
|
|
209
|
+
clackNote(`配置文档:${url}`, "Docs");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isRecord(value: unknown): value is ConfigRecord {
|
|
213
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveWriteConfig(runtime: unknown): WriteConfigLike | undefined {
|
|
217
|
+
if (!isRecord(runtime)) {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
const config = runtime.config;
|
|
221
|
+
if (!isRecord(config)) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
if (typeof config.writeConfigFile !== "function") {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
return config.writeConfigFile as WriteConfigLike;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isCommandLike(value: unknown): value is CommandLike {
|
|
231
|
+
if (!isRecord(value)) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
return (
|
|
235
|
+
typeof value.command === "function" &&
|
|
236
|
+
typeof value.description === "function" &&
|
|
237
|
+
typeof value.action === "function"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function toTrimmedString(value: unknown): string | undefined {
|
|
242
|
+
if (typeof value !== "string") {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
const trimmed = value.trim();
|
|
246
|
+
return trimmed || undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function hasNonEmptyString(value: unknown): boolean {
|
|
250
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function toBoolean(value: unknown, fallback: boolean): boolean {
|
|
254
|
+
return typeof value === "boolean" ? value : fallback;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function toNumber(value: unknown): number | undefined {
|
|
258
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function cloneConfig(cfg: ConfigRoot): ConfigRoot {
|
|
262
|
+
try {
|
|
263
|
+
return structuredClone(cfg);
|
|
264
|
+
} catch {
|
|
265
|
+
return JSON.parse(JSON.stringify(cfg)) as ConfigRoot;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getChannelConfig(cfg: ConfigRoot, channelId: ChannelId): ConfigRecord {
|
|
270
|
+
const channels = isRecord(cfg.channels) ? cfg.channels : {};
|
|
271
|
+
const existing = channels[channelId];
|
|
272
|
+
return isRecord(existing) ? existing : {};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function getPreferredAccountConfig(channelCfg: ConfigRecord): ConfigRecord | undefined {
|
|
276
|
+
const accounts = channelCfg.accounts;
|
|
277
|
+
if (!isRecord(accounts)) {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const defaultAccountId = toTrimmedString(channelCfg.defaultAccount);
|
|
282
|
+
if (defaultAccountId) {
|
|
283
|
+
const preferred = accounts[defaultAccountId];
|
|
284
|
+
if (isRecord(preferred)) {
|
|
285
|
+
return preferred;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const value of Object.values(accounts)) {
|
|
290
|
+
if (isRecord(value)) {
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function hasTokenPair(channelCfg: ConfigRecord): boolean {
|
|
298
|
+
if (hasNonEmptyString(channelCfg.token) && hasNonEmptyString(channelCfg.encodingAESKey)) {
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
const accountCfg = getPreferredAccountConfig(channelCfg);
|
|
302
|
+
return Boolean(
|
|
303
|
+
accountCfg &&
|
|
304
|
+
hasNonEmptyString(accountCfg.token) &&
|
|
305
|
+
hasNonEmptyString(accountCfg.encodingAESKey)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isChannelConfigured(cfg: ConfigRoot, channelId: ChannelId): boolean {
|
|
310
|
+
const channelCfg = getChannelConfig(cfg, channelId);
|
|
311
|
+
switch (channelId) {
|
|
312
|
+
case "dingtalk":
|
|
313
|
+
return hasNonEmptyString(channelCfg.clientId) && hasNonEmptyString(channelCfg.clientSecret);
|
|
314
|
+
case "feishu-china":
|
|
315
|
+
return hasNonEmptyString(channelCfg.appId) && hasNonEmptyString(channelCfg.appSecret);
|
|
316
|
+
case "qqbot":
|
|
317
|
+
return hasNonEmptyString(channelCfg.appId) && hasNonEmptyString(channelCfg.clientSecret);
|
|
318
|
+
case "wecom":
|
|
319
|
+
case "wecom-app":
|
|
320
|
+
return hasTokenPair(channelCfg);
|
|
321
|
+
default:
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function withConfiguredSuffix(cfg: ConfigRoot, channelId: ChannelId): string {
|
|
327
|
+
const base = CHANNEL_DISPLAY_LABELS[channelId];
|
|
328
|
+
return isChannelConfigured(cfg, channelId) ? `${base}(已配置)` : base;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function mergeChannelConfig(
|
|
332
|
+
cfg: ConfigRoot,
|
|
333
|
+
channelId: ChannelId,
|
|
334
|
+
patch: ConfigRecord
|
|
335
|
+
): ConfigRoot {
|
|
336
|
+
const channels = isRecord(cfg.channels) ? { ...cfg.channels } : {};
|
|
337
|
+
const existing = getChannelConfig(cfg, channelId);
|
|
338
|
+
channels[channelId] = {
|
|
339
|
+
...existing,
|
|
340
|
+
...patch,
|
|
341
|
+
enabled: true,
|
|
342
|
+
};
|
|
343
|
+
return {
|
|
344
|
+
...cfg,
|
|
345
|
+
channels,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
class SetupPrompter {
|
|
350
|
+
async askText(params: {
|
|
351
|
+
label: string;
|
|
352
|
+
required?: boolean;
|
|
353
|
+
defaultValue?: string;
|
|
354
|
+
}): Promise<string> {
|
|
355
|
+
const { label, required = false, defaultValue } = params;
|
|
356
|
+
while (true) {
|
|
357
|
+
const value = String(
|
|
358
|
+
guardCancel(
|
|
359
|
+
await clackText({
|
|
360
|
+
message: label,
|
|
361
|
+
initialValue: defaultValue,
|
|
362
|
+
})
|
|
363
|
+
)
|
|
364
|
+
).trim();
|
|
365
|
+
|
|
366
|
+
if (value) {
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
if (defaultValue) {
|
|
370
|
+
return defaultValue;
|
|
371
|
+
}
|
|
372
|
+
if (!required) {
|
|
373
|
+
return "";
|
|
374
|
+
}
|
|
375
|
+
warn("该字段为必填项。");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async askSecret(params: {
|
|
380
|
+
label: string;
|
|
381
|
+
existingValue?: string;
|
|
382
|
+
required?: boolean;
|
|
383
|
+
}): Promise<string> {
|
|
384
|
+
const { label, existingValue, required = true } = params;
|
|
385
|
+
return this.askText({
|
|
386
|
+
label,
|
|
387
|
+
required,
|
|
388
|
+
defaultValue: existingValue,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async askConfirm(label: string, defaultValue = true): Promise<boolean> {
|
|
393
|
+
return Boolean(
|
|
394
|
+
guardCancel(
|
|
395
|
+
await clackConfirm({
|
|
396
|
+
message: label,
|
|
397
|
+
initialValue: defaultValue,
|
|
398
|
+
})
|
|
399
|
+
)
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async askNumber(params: {
|
|
404
|
+
label: string;
|
|
405
|
+
min?: number;
|
|
406
|
+
defaultValue?: number;
|
|
407
|
+
}): Promise<number> {
|
|
408
|
+
const { label, min, defaultValue } = params;
|
|
409
|
+
while (true) {
|
|
410
|
+
const raw = String(
|
|
411
|
+
guardCancel(
|
|
412
|
+
await clackText({
|
|
413
|
+
message: label,
|
|
414
|
+
initialValue: defaultValue !== undefined ? String(defaultValue) : undefined,
|
|
415
|
+
})
|
|
416
|
+
)
|
|
417
|
+
).trim();
|
|
418
|
+
|
|
419
|
+
const parsed = Number.parseInt(raw, 10);
|
|
420
|
+
if (Number.isFinite(parsed) && (min === undefined || parsed >= min)) {
|
|
421
|
+
return parsed;
|
|
422
|
+
}
|
|
423
|
+
warn(`请输入有效整数${min !== undefined ? `(>= ${min})` : ""}。`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async askSelect<T extends string>(
|
|
428
|
+
message: string,
|
|
429
|
+
options: Array<Option<T>>,
|
|
430
|
+
defaultValue: T
|
|
431
|
+
): Promise<T> {
|
|
432
|
+
const initial = options.some((opt) => opt.value === defaultValue)
|
|
433
|
+
? defaultValue
|
|
434
|
+
: options[0]?.value;
|
|
435
|
+
const selectOptions = options.map((option) => ({
|
|
436
|
+
value: option.value,
|
|
437
|
+
label: option.label,
|
|
438
|
+
})) as Parameters<typeof clackSelect<T>>[0]["options"];
|
|
439
|
+
|
|
440
|
+
return guardCancel(
|
|
441
|
+
await clackSelect<T>({
|
|
442
|
+
message,
|
|
443
|
+
options: selectOptions,
|
|
444
|
+
initialValue: initial,
|
|
445
|
+
})
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function configureDingtalk(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
|
|
451
|
+
section("配置 DingTalk(钉钉)");
|
|
452
|
+
showGuideLink("dingtalk");
|
|
453
|
+
const existing = getChannelConfig(cfg, "dingtalk");
|
|
454
|
+
|
|
455
|
+
const clientId = await prompter.askText({
|
|
456
|
+
label: "DingTalk clientId(AppKey)",
|
|
457
|
+
defaultValue: toTrimmedString(existing.clientId),
|
|
458
|
+
required: true,
|
|
459
|
+
});
|
|
460
|
+
const clientSecret = await prompter.askSecret({
|
|
461
|
+
label: "DingTalk clientSecret(AppSecret)",
|
|
462
|
+
existingValue: toTrimmedString(existing.clientSecret),
|
|
463
|
+
required: true,
|
|
464
|
+
});
|
|
465
|
+
const enableAICard = await prompter.askConfirm(
|
|
466
|
+
"启用 AI Card 流式回复(推荐关闭,使用非流式)",
|
|
467
|
+
toBoolean(existing.enableAICard, false)
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
return mergeChannelConfig(cfg, "dingtalk", {
|
|
471
|
+
clientId,
|
|
472
|
+
clientSecret,
|
|
473
|
+
enableAICard,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function configureFeishu(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
|
|
478
|
+
section("配置 Feishu(飞书)");
|
|
479
|
+
showGuideLink("feishu-china");
|
|
480
|
+
const existing = getChannelConfig(cfg, "feishu-china");
|
|
481
|
+
|
|
482
|
+
const appId = await prompter.askText({
|
|
483
|
+
label: "Feishu appId",
|
|
484
|
+
defaultValue: toTrimmedString(existing.appId),
|
|
485
|
+
required: true,
|
|
486
|
+
});
|
|
487
|
+
const appSecret = await prompter.askSecret({
|
|
488
|
+
label: "Feishu appSecret",
|
|
489
|
+
existingValue: toTrimmedString(existing.appSecret),
|
|
490
|
+
required: true,
|
|
491
|
+
});
|
|
492
|
+
const sendMarkdownAsCard = await prompter.askConfirm(
|
|
493
|
+
"以卡片形式发送 Markdown",
|
|
494
|
+
toBoolean(existing.sendMarkdownAsCard, true)
|
|
495
|
+
);
|
|
496
|
+
return mergeChannelConfig(cfg, "feishu-china", {
|
|
497
|
+
appId,
|
|
498
|
+
appSecret,
|
|
499
|
+
sendMarkdownAsCard,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function configureWecom(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
|
|
504
|
+
section("配置 WeCom(企业微信-智能机器人)");
|
|
505
|
+
showGuideLink("wecom");
|
|
506
|
+
const existing = getChannelConfig(cfg, "wecom");
|
|
507
|
+
|
|
508
|
+
const webhookPath = await prompter.askText({
|
|
509
|
+
label: "Webhook 路径(需与企业微信后台配置一致,默认 /wecom)",
|
|
510
|
+
defaultValue: toTrimmedString(existing.webhookPath) ?? "/wecom",
|
|
511
|
+
required: true,
|
|
512
|
+
});
|
|
513
|
+
const token = await prompter.askSecret({
|
|
514
|
+
label: "WeCom token",
|
|
515
|
+
existingValue: toTrimmedString(existing.token),
|
|
516
|
+
required: true,
|
|
517
|
+
});
|
|
518
|
+
const encodingAESKey = await prompter.askSecret({
|
|
519
|
+
label: "WeCom encodingAESKey",
|
|
520
|
+
existingValue: toTrimmedString(existing.encodingAESKey),
|
|
521
|
+
required: true,
|
|
522
|
+
});
|
|
523
|
+
return mergeChannelConfig(cfg, "wecom", {
|
|
524
|
+
webhookPath,
|
|
525
|
+
token,
|
|
526
|
+
encodingAESKey,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function configureWecomApp(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
|
|
531
|
+
section("配置 WeCom App(自建应用-可接入微信)");
|
|
532
|
+
showGuideLink("wecom-app");
|
|
533
|
+
const existing = getChannelConfig(cfg, "wecom-app");
|
|
534
|
+
|
|
535
|
+
const webhookPath = await prompter.askText({
|
|
536
|
+
label: "Webhook 路径(需与企业微信后台配置一致,默认 /wecom-app)",
|
|
537
|
+
defaultValue: toTrimmedString(existing.webhookPath) ?? "/wecom-app",
|
|
538
|
+
required: true,
|
|
539
|
+
});
|
|
540
|
+
const token = await prompter.askSecret({
|
|
541
|
+
label: "WeCom App token",
|
|
542
|
+
existingValue: toTrimmedString(existing.token),
|
|
543
|
+
required: true,
|
|
544
|
+
});
|
|
545
|
+
const encodingAESKey = await prompter.askSecret({
|
|
546
|
+
label: "WeCom App encodingAESKey",
|
|
547
|
+
existingValue: toTrimmedString(existing.encodingAESKey),
|
|
548
|
+
required: true,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const patch: ConfigRecord = {
|
|
552
|
+
webhookPath,
|
|
553
|
+
token,
|
|
554
|
+
encodingAESKey,
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const corpId = await prompter.askText({
|
|
558
|
+
label: "corpId",
|
|
559
|
+
defaultValue: toTrimmedString(existing.corpId),
|
|
560
|
+
required: true,
|
|
561
|
+
});
|
|
562
|
+
const corpSecret = await prompter.askSecret({
|
|
563
|
+
label: "corpSecret",
|
|
564
|
+
existingValue: toTrimmedString(existing.corpSecret),
|
|
565
|
+
required: true,
|
|
566
|
+
});
|
|
567
|
+
const agentId = await prompter.askNumber({
|
|
568
|
+
label: "agentId",
|
|
569
|
+
min: 1,
|
|
570
|
+
defaultValue: toNumber(existing.agentId),
|
|
571
|
+
});
|
|
572
|
+
patch.corpId = corpId;
|
|
573
|
+
patch.corpSecret = corpSecret;
|
|
574
|
+
patch.agentId = agentId;
|
|
575
|
+
|
|
576
|
+
return mergeChannelConfig(cfg, "wecom-app", patch);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function configureQQBot(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
|
|
580
|
+
section("配置 QQBot(QQ 机器人)");
|
|
581
|
+
showGuideLink("qqbot");
|
|
582
|
+
const existing = getChannelConfig(cfg, "qqbot");
|
|
583
|
+
|
|
584
|
+
const appId = await prompter.askText({
|
|
585
|
+
label: "QQBot appId",
|
|
586
|
+
defaultValue: toTrimmedString(existing.appId),
|
|
587
|
+
required: true,
|
|
588
|
+
});
|
|
589
|
+
const clientSecret = await prompter.askSecret({
|
|
590
|
+
label: "QQBot clientSecret",
|
|
591
|
+
existingValue: toTrimmedString(existing.clientSecret),
|
|
592
|
+
required: true,
|
|
593
|
+
});
|
|
594
|
+
const markdownSupport = await prompter.askConfirm(
|
|
595
|
+
"启用 Markdown 支持",
|
|
596
|
+
toBoolean(existing.markdownSupport, false)
|
|
597
|
+
);
|
|
598
|
+
const replyFinalOnly = await prompter.askConfirm(
|
|
599
|
+
"仅发送最终回复(关闭流式分片)",
|
|
600
|
+
toBoolean(existing.replyFinalOnly, false)
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
return mergeChannelConfig(cfg, "qqbot", {
|
|
604
|
+
appId,
|
|
605
|
+
clientSecret,
|
|
606
|
+
markdownSupport,
|
|
607
|
+
replyFinalOnly,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function configureSingleChannel(
|
|
612
|
+
channel: ChannelId,
|
|
613
|
+
prompter: SetupPrompter,
|
|
614
|
+
cfg: ConfigRoot
|
|
615
|
+
): Promise<ConfigRoot> {
|
|
616
|
+
switch (channel) {
|
|
617
|
+
case "dingtalk":
|
|
618
|
+
return configureDingtalk(prompter, cfg);
|
|
619
|
+
case "feishu-china":
|
|
620
|
+
return configureFeishu(prompter, cfg);
|
|
621
|
+
case "wecom":
|
|
622
|
+
return configureWecom(prompter, cfg);
|
|
623
|
+
case "wecom-app":
|
|
624
|
+
return configureWecomApp(prompter, cfg);
|
|
625
|
+
case "qqbot":
|
|
626
|
+
return configureQQBot(prompter, cfg);
|
|
627
|
+
default:
|
|
628
|
+
return cfg;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function runChinaSetup(params: {
|
|
633
|
+
initialConfig: ConfigRoot;
|
|
634
|
+
writeConfig?: WriteConfigLike;
|
|
635
|
+
logger: LoggerLike;
|
|
636
|
+
availableChannels: readonly ChannelId[];
|
|
637
|
+
}): Promise<void> {
|
|
638
|
+
if (!input.isTTY || !output.isTTY) {
|
|
639
|
+
params.logger.error?.("交互式配置需要在 TTY 终端中运行。");
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const prompter = new SetupPrompter();
|
|
644
|
+
const touched = new Set<ChannelId>();
|
|
645
|
+
let next = cloneConfig(params.initialConfig);
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
clackIntro("OpenClaw China 配置向导");
|
|
649
|
+
clackNote(
|
|
650
|
+
[
|
|
651
|
+
"使用方向键选择,按 Enter 确认。",
|
|
652
|
+
`项目仓库:${ANSI_LINK}${PROJECT_REPO}${ANSI_RESET}`,
|
|
653
|
+
].join("\n"),
|
|
654
|
+
"欢迎"
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
if (params.availableChannels.length === 0) {
|
|
658
|
+
params.logger.error?.("未检测到可配置的 China 渠道插件。");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const channelOptions = params.availableChannels.map((channelId, index) => ({
|
|
663
|
+
key: index === 0 ? "recommended" : "",
|
|
664
|
+
value: channelId,
|
|
665
|
+
label: withConfiguredSuffix(next, channelId),
|
|
666
|
+
}));
|
|
667
|
+
const defaultChannel = channelOptions[0]?.value ?? "save";
|
|
668
|
+
|
|
669
|
+
let continueLoop = true;
|
|
670
|
+
while (continueLoop) {
|
|
671
|
+
const selected = await prompter.askSelect<ChannelId | "save" | "cancel">(
|
|
672
|
+
"请选择要配置的渠道",
|
|
673
|
+
[
|
|
674
|
+
...channelOptions,
|
|
675
|
+
{ key: "", value: "save", label: "保存并退出" },
|
|
676
|
+
{ key: "", value: "cancel", label: "不保存并退出" },
|
|
677
|
+
],
|
|
678
|
+
defaultChannel
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
if (selected === "cancel") {
|
|
682
|
+
clackCancel("已取消,未写入任何配置。");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (selected === "save") {
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
next = await configureSingleChannel(selected, prompter, next);
|
|
691
|
+
touched.add(selected);
|
|
692
|
+
clackNote(`已完成:${CHANNEL_DISPLAY_LABELS[selected]}`, "完成");
|
|
693
|
+
|
|
694
|
+
continueLoop = await prompter.askConfirm("继续配置其他渠道", true);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (touched.size === 0) {
|
|
698
|
+
clackCancel("未进行任何修改。");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
clackNote(
|
|
703
|
+
`已配置渠道:${Array.from(touched)
|
|
704
|
+
.map((channelId) => CHANNEL_DISPLAY_LABELS[channelId])
|
|
705
|
+
.join(", ")}`,
|
|
706
|
+
"摘要"
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
if (!params.writeConfig) {
|
|
710
|
+
params.logger.error?.("无法保存配置:当前运行时未提供配置写入能力。");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
await params.writeConfig(next);
|
|
715
|
+
clackOutro("配置已保存。");
|
|
716
|
+
showReadyMessage();
|
|
717
|
+
} catch (err) {
|
|
718
|
+
if (err instanceof PromptCancelledError) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
throw err;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function registerChinaSetupCli(api: ApiLike, opts?: RegisterChinaSetupCliOptions): void {
|
|
726
|
+
const state = getChinaCliState();
|
|
727
|
+
for (const channelId of normalizeChannels(opts?.channels)) {
|
|
728
|
+
state.channels.add(channelId);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (state.cliRegistered || typeof api.registerCli !== "function") {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
state.cliRegistered = true;
|
|
735
|
+
|
|
736
|
+
const writeConfig = resolveWriteConfig(api.runtime);
|
|
737
|
+
const fallbackLogger: LoggerLike = {
|
|
738
|
+
info: (message) => output.write(`${message}\n`),
|
|
739
|
+
warn: (message) => warn(message),
|
|
740
|
+
error: (message) => warn(message),
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
api.registerCli(
|
|
744
|
+
(ctx) => {
|
|
745
|
+
if (!isCommandLike(ctx.program)) {
|
|
746
|
+
const logger = ctx.logger ?? api.logger ?? fallbackLogger;
|
|
747
|
+
logger.error?.("无法注册 china 命令:CLI program 实例无效。");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const root = ctx.program.command("china").description("OpenClaw China 插件命令");
|
|
752
|
+
|
|
753
|
+
root
|
|
754
|
+
.command("setup")
|
|
755
|
+
.description("中国渠道交互式配置向导")
|
|
756
|
+
.action(async () => {
|
|
757
|
+
const logger = ctx.logger ?? api.logger ?? fallbackLogger;
|
|
758
|
+
const availableChannels = getInstalledChannels(state);
|
|
759
|
+
await runChinaSetup({
|
|
760
|
+
initialConfig: isRecord(ctx.config) ? (ctx.config as ConfigRoot) : {},
|
|
761
|
+
writeConfig,
|
|
762
|
+
logger,
|
|
763
|
+
availableChannels,
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
root.command("about").description("显示项目信息").action(() => {
|
|
768
|
+
const installed = getInstalledChannels(state);
|
|
769
|
+
clackIntro("OpenClaw China 渠道插件");
|
|
770
|
+
clackNote(
|
|
771
|
+
installed.length > 0
|
|
772
|
+
? `当前已安装渠道:${installed.map((channelId) => CHANNEL_DISPLAY_LABELS[channelId]).join("、")}`
|
|
773
|
+
: "OpenClaw China 渠道插件",
|
|
774
|
+
"关于"
|
|
775
|
+
);
|
|
776
|
+
clackOutro(PROJECT_REPO);
|
|
777
|
+
showReadyMessage();
|
|
778
|
+
});
|
|
779
|
+
},
|
|
780
|
+
{ commands: ["china"] }
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|