@openclaw-china/shared 2026.3.8-3 → 2026.3.9-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/package.json +1 -1
- package/src/cli/china-setup.test.ts +180 -0
- package/src/cli/china-setup.ts +68 -60
- package/src/cli/index.ts +2 -2
- package/src/cron/index.ts +16 -16
- 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/index.ts +3 -3
- package/src/logger/index.ts +1 -1
- package/src/logger/logger.ts +51 -51
- package/src/media/index.ts +22 -22
- package/vitest.config.ts +8 -8
package/package.json
CHANGED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const cancelMock = vi.fn();
|
|
4
|
+
const confirmMock = vi.fn();
|
|
5
|
+
const introMock = vi.fn();
|
|
6
|
+
const noteMock = vi.fn();
|
|
7
|
+
const outroMock = vi.fn();
|
|
8
|
+
const selectMock = vi.fn();
|
|
9
|
+
const textMock = vi.fn();
|
|
10
|
+
|
|
11
|
+
vi.mock("@clack/prompts", () => ({
|
|
12
|
+
cancel: (...args: unknown[]) => cancelMock(...args),
|
|
13
|
+
confirm: (...args: unknown[]) => confirmMock(...args),
|
|
14
|
+
intro: (...args: unknown[]) => introMock(...args),
|
|
15
|
+
isCancel: () => false,
|
|
16
|
+
note: (...args: unknown[]) => noteMock(...args),
|
|
17
|
+
outro: (...args: unknown[]) => outroMock(...args),
|
|
18
|
+
select: (...args: unknown[]) => selectMock(...args),
|
|
19
|
+
text: (...args: unknown[]) => textMock(...args),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { registerChinaSetupCli } from "./china-setup.js";
|
|
23
|
+
|
|
24
|
+
type ActionHandler = () => void | Promise<void>;
|
|
25
|
+
|
|
26
|
+
type LoggerLike = {
|
|
27
|
+
info?: (message: string) => void;
|
|
28
|
+
warn?: (message: string) => void;
|
|
29
|
+
error?: (message: string) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type CommandNode = {
|
|
33
|
+
children: Map<string, CommandNode>;
|
|
34
|
+
actionHandler?: ActionHandler;
|
|
35
|
+
command: (name: string) => CommandNode;
|
|
36
|
+
description: (text: string) => CommandNode;
|
|
37
|
+
action: (handler: ActionHandler) => CommandNode;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ConfigRoot = {
|
|
41
|
+
channels?: Record<string, Record<string, unknown>>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const CLI_STATE_KEY = Symbol.for("@openclaw-china/china-cli-state");
|
|
45
|
+
|
|
46
|
+
function createCommandNode(): CommandNode {
|
|
47
|
+
const node: CommandNode = {
|
|
48
|
+
children: new Map<string, CommandNode>(),
|
|
49
|
+
command(name: string): CommandNode {
|
|
50
|
+
const child = createCommandNode();
|
|
51
|
+
node.children.set(name, child);
|
|
52
|
+
return child;
|
|
53
|
+
},
|
|
54
|
+
description(): CommandNode {
|
|
55
|
+
return node;
|
|
56
|
+
},
|
|
57
|
+
action(handler: ActionHandler): CommandNode {
|
|
58
|
+
node.actionHandler = handler;
|
|
59
|
+
return node;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
return node;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runSetup(initialConfig: ConfigRoot): Promise<{
|
|
66
|
+
writeConfigFile: ReturnType<typeof vi.fn>;
|
|
67
|
+
}> {
|
|
68
|
+
let registrar:
|
|
69
|
+
| ((ctx: { program: unknown; config?: unknown; logger?: LoggerLike }) => void | Promise<void>)
|
|
70
|
+
| undefined;
|
|
71
|
+
const writeConfigFile = vi.fn(async (_cfg: ConfigRoot) => {});
|
|
72
|
+
|
|
73
|
+
registerChinaSetupCli(
|
|
74
|
+
{
|
|
75
|
+
runtime: {
|
|
76
|
+
config: {
|
|
77
|
+
writeConfigFile,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
registerCli: (nextRegistrar) => {
|
|
81
|
+
registrar = nextRegistrar;
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{ channels: ["wecom"] }
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const program = createCommandNode();
|
|
88
|
+
await registrar?.({
|
|
89
|
+
program,
|
|
90
|
+
config: initialConfig,
|
|
91
|
+
logger: {},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const setupCommand = program.children.get("china")?.children.get("setup");
|
|
95
|
+
expect(setupCommand?.actionHandler).toBeTypeOf("function");
|
|
96
|
+
await setupCommand?.actionHandler?.();
|
|
97
|
+
|
|
98
|
+
return { writeConfigFile };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("china setup wecom", () => {
|
|
102
|
+
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
|
103
|
+
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
vi.clearAllMocks();
|
|
107
|
+
delete (globalThis as Record<PropertyKey, unknown>)[CLI_STATE_KEY];
|
|
108
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
109
|
+
configurable: true,
|
|
110
|
+
value: true,
|
|
111
|
+
});
|
|
112
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
113
|
+
configurable: true,
|
|
114
|
+
value: true,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
if (stdinDescriptor) {
|
|
120
|
+
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
|
|
121
|
+
}
|
|
122
|
+
if (stdoutDescriptor) {
|
|
123
|
+
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("stores ws-only credentials for wecom setup", async () => {
|
|
128
|
+
selectMock.mockResolvedValueOnce("wecom");
|
|
129
|
+
textMock.mockResolvedValueOnce("bot-123").mockResolvedValueOnce("secret-456");
|
|
130
|
+
confirmMock.mockResolvedValueOnce(false);
|
|
131
|
+
|
|
132
|
+
const { writeConfigFile } = await runSetup({
|
|
133
|
+
channels: {
|
|
134
|
+
wecom: {
|
|
135
|
+
webhookPath: "/legacy-wecom",
|
|
136
|
+
token: "legacy-token",
|
|
137
|
+
encodingAESKey: "legacy-aes-key",
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
|
143
|
+
const savedConfig = writeConfigFile.mock.calls[0]?.[0] as ConfigRoot;
|
|
144
|
+
const wecomConfig = savedConfig.channels?.wecom;
|
|
145
|
+
|
|
146
|
+
expect(wecomConfig?.enabled).toBe(true);
|
|
147
|
+
expect(wecomConfig?.mode).toBe("ws");
|
|
148
|
+
expect(wecomConfig?.botId).toBe("bot-123");
|
|
149
|
+
expect(wecomConfig?.secret).toBe("secret-456");
|
|
150
|
+
expect(wecomConfig?.webhookPath).toBeUndefined();
|
|
151
|
+
expect(wecomConfig?.token).toBeUndefined();
|
|
152
|
+
expect(wecomConfig?.encodingAESKey).toBeUndefined();
|
|
153
|
+
|
|
154
|
+
const promptMessages = textMock.mock.calls.map((call) => {
|
|
155
|
+
const firstArg = call[0] as { message?: string } | undefined;
|
|
156
|
+
return firstArg?.message ?? "";
|
|
157
|
+
});
|
|
158
|
+
expect(promptMessages).toEqual(["WeCom botId(ws 长连接)", "WeCom secret(ws 长连接)"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("marks wecom as configured when botId and secret already exist", async () => {
|
|
162
|
+
let selectOptions: Array<{ label?: string; value?: string }> = [];
|
|
163
|
+
selectMock.mockImplementationOnce(async (params: { options?: Array<{ label?: string; value?: string }> }) => {
|
|
164
|
+
selectOptions = params.options ?? [];
|
|
165
|
+
return "cancel";
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const { writeConfigFile } = await runSetup({
|
|
169
|
+
channels: {
|
|
170
|
+
wecom: {
|
|
171
|
+
botId: "existing-bot",
|
|
172
|
+
secret: "existing-secret",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(writeConfigFile).not.toHaveBeenCalled();
|
|
178
|
+
expect(selectOptions.some((option) => option.label === "WeCom(企业微信-智能机器人)(已配置)")).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
});
|
package/src/cli/china-setup.ts
CHANGED
|
@@ -294,17 +294,25 @@ function getPreferredAccountConfig(channelCfg: ConfigRecord): ConfigRecord | und
|
|
|
294
294
|
return undefined;
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
-
function
|
|
298
|
-
if (hasNonEmptyString(channelCfg
|
|
299
|
-
return true;
|
|
300
|
-
}
|
|
301
|
-
const accountCfg = getPreferredAccountConfig(channelCfg);
|
|
302
|
-
return Boolean(
|
|
303
|
-
accountCfg &&
|
|
304
|
-
hasNonEmptyString(accountCfg
|
|
305
|
-
hasNonEmptyString(accountCfg
|
|
306
|
-
);
|
|
307
|
-
}
|
|
297
|
+
function hasCredentialPair(channelCfg: ConfigRecord, firstKey: string, secondKey: string): boolean {
|
|
298
|
+
if (hasNonEmptyString(channelCfg[firstKey]) && hasNonEmptyString(channelCfg[secondKey])) {
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
const accountCfg = getPreferredAccountConfig(channelCfg);
|
|
302
|
+
return Boolean(
|
|
303
|
+
accountCfg &&
|
|
304
|
+
hasNonEmptyString(accountCfg[firstKey]) &&
|
|
305
|
+
hasNonEmptyString(accountCfg[secondKey])
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function hasTokenPair(channelCfg: ConfigRecord): boolean {
|
|
310
|
+
return hasCredentialPair(channelCfg, "token", "encodingAESKey");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function hasWecomWsCredentialPair(channelCfg: ConfigRecord): boolean {
|
|
314
|
+
return hasCredentialPair(channelCfg, "botId", "secret");
|
|
315
|
+
}
|
|
308
316
|
|
|
309
317
|
function isChannelConfigured(cfg: ConfigRoot, channelId: ChannelId): boolean {
|
|
310
318
|
const channelCfg = getChannelConfig(cfg, channelId);
|
|
@@ -313,14 +321,15 @@ function isChannelConfigured(cfg: ConfigRoot, channelId: ChannelId): boolean {
|
|
|
313
321
|
return hasNonEmptyString(channelCfg.clientId) && hasNonEmptyString(channelCfg.clientSecret);
|
|
314
322
|
case "feishu-china":
|
|
315
323
|
return hasNonEmptyString(channelCfg.appId) && hasNonEmptyString(channelCfg.appSecret);
|
|
316
|
-
case "qqbot":
|
|
317
|
-
return hasNonEmptyString(channelCfg.appId) && hasNonEmptyString(channelCfg.clientSecret);
|
|
318
|
-
case "wecom":
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
+
case "qqbot":
|
|
325
|
+
return hasNonEmptyString(channelCfg.appId) && hasNonEmptyString(channelCfg.clientSecret);
|
|
326
|
+
case "wecom":
|
|
327
|
+
return hasWecomWsCredentialPair(channelCfg);
|
|
328
|
+
case "wecom-app":
|
|
329
|
+
return hasTokenPair(channelCfg);
|
|
330
|
+
default:
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
324
333
|
}
|
|
325
334
|
|
|
326
335
|
function withConfiguredSuffix(cfg: ConfigRoot, channelId: ChannelId): string {
|
|
@@ -500,32 +509,31 @@ async function configureFeishu(prompter: SetupPrompter, cfg: ConfigRoot): Promis
|
|
|
500
509
|
});
|
|
501
510
|
}
|
|
502
511
|
|
|
503
|
-
async function configureWecom(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
|
|
504
|
-
section("配置 WeCom(企业微信-智能机器人)");
|
|
505
|
-
showGuideLink("wecom");
|
|
506
|
-
const existing = getChannelConfig(cfg, "wecom");
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
512
|
+
async function configureWecom(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
|
|
513
|
+
section("配置 WeCom(企业微信-智能机器人)");
|
|
514
|
+
showGuideLink("wecom");
|
|
515
|
+
const existing = getChannelConfig(cfg, "wecom");
|
|
516
|
+
clackNote("当前向导仅提供 WeCom ws 长连接配置。", "提示");
|
|
517
|
+
|
|
518
|
+
const botId = await prompter.askText({
|
|
519
|
+
label: "WeCom botId(ws 长连接)",
|
|
520
|
+
defaultValue: toTrimmedString(existing.botId),
|
|
521
|
+
required: true,
|
|
522
|
+
});
|
|
523
|
+
const secret = await prompter.askSecret({
|
|
524
|
+
label: "WeCom secret(ws 长连接)",
|
|
525
|
+
existingValue: toTrimmedString(existing.secret),
|
|
526
|
+
required: true,
|
|
527
|
+
});
|
|
528
|
+
return mergeChannelConfig(cfg, "wecom", {
|
|
529
|
+
mode: "ws",
|
|
530
|
+
botId,
|
|
531
|
+
secret,
|
|
532
|
+
webhookPath: undefined,
|
|
533
|
+
token: undefined,
|
|
534
|
+
encodingAESKey: undefined,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
529
537
|
|
|
530
538
|
async function configureWecomApp(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
|
|
531
539
|
section("配置 WeCom App(自建应用-可接入微信)");
|
|
@@ -620,15 +628,15 @@ async function configureQQBot(prompter: SetupPrompter, cfg: ConfigRoot): Promise
|
|
|
620
628
|
defaultValue: toTrimmedString(existing.appId),
|
|
621
629
|
required: true,
|
|
622
630
|
});
|
|
623
|
-
const clientSecret = await prompter.askSecret({
|
|
624
|
-
label: "QQBot clientSecret",
|
|
625
|
-
existingValue: toTrimmedString(existing.clientSecret),
|
|
626
|
-
required: true,
|
|
627
|
-
});
|
|
628
|
-
const asrEnabled = await prompter.askConfirm(
|
|
629
|
-
"启用 ASR(支持入站语音自动转文字)",
|
|
630
|
-
toBoolean(existingAsr.enabled, false)
|
|
631
|
-
);
|
|
631
|
+
const clientSecret = await prompter.askSecret({
|
|
632
|
+
label: "QQBot clientSecret",
|
|
633
|
+
existingValue: toTrimmedString(existing.clientSecret),
|
|
634
|
+
required: true,
|
|
635
|
+
});
|
|
636
|
+
const asrEnabled = await prompter.askConfirm(
|
|
637
|
+
"启用 ASR(支持入站语音自动转文字)",
|
|
638
|
+
toBoolean(existingAsr.enabled, false)
|
|
639
|
+
);
|
|
632
640
|
|
|
633
641
|
const asr: ConfigRecord = {
|
|
634
642
|
enabled: asrEnabled,
|
|
@@ -652,12 +660,12 @@ async function configureQQBot(prompter: SetupPrompter, cfg: ConfigRoot): Promise
|
|
|
652
660
|
});
|
|
653
661
|
}
|
|
654
662
|
|
|
655
|
-
return mergeChannelConfig(cfg, "qqbot", {
|
|
656
|
-
appId,
|
|
657
|
-
clientSecret,
|
|
658
|
-
asr,
|
|
659
|
-
});
|
|
660
|
-
}
|
|
663
|
+
return mergeChannelConfig(cfg, "qqbot", {
|
|
664
|
+
appId,
|
|
665
|
+
clientSecret,
|
|
666
|
+
asr,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
661
669
|
|
|
662
670
|
async function configureSingleChannel(
|
|
663
671
|
channel: ChannelId,
|
package/src/cli/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from "./china-setup.js";
|
|
2
|
-
export * from "./install-hint.js";
|
|
1
|
+
export * from "./china-setup.js";
|
|
2
|
+
export * from "./install-hint.js";
|
package/src/cron/index.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
const CRON_HIDDEN_PROMPT = `
|
|
2
|
-
When creating or updating a cron task, always store a fixed delivery target in the job itself.
|
|
3
|
-
- Use the built-in cron tool (action=add/update). Do not run shell commands.
|
|
4
|
-
- Must use sessionTarget="isolated" for reminder jobs.
|
|
5
|
-
- payload.kind="agentTurn"
|
|
6
|
-
- payload.message must be plain user-visible reminder text only.
|
|
7
|
-
- You must encode runtime guardrails directly into payload.message so the cron run can follow them without extra context.
|
|
8
|
-
- Runtime guardrails to encode in payload.message:
|
|
9
|
-
- return plain text only
|
|
10
|
-
- never call any tool
|
|
11
|
-
- never call the message tool
|
|
12
|
-
- never send manually; delivery is handled by cron delivery settings
|
|
13
|
-
- Do not include tool directives, "NO_REPLY", or heartbeat markers in payload.message.
|
|
14
|
-
- Job name is never a message target.
|
|
15
|
-
- During cron run, must return plain text only and never call the message tool.
|
|
16
|
-
- Use top-level delivery with announce mode:
|
|
1
|
+
const CRON_HIDDEN_PROMPT = `
|
|
2
|
+
When creating or updating a cron task, always store a fixed delivery target in the job itself.
|
|
3
|
+
- Use the built-in cron tool (action=add/update). Do not run shell commands.
|
|
4
|
+
- Must use sessionTarget="isolated" for reminder jobs.
|
|
5
|
+
- payload.kind="agentTurn"
|
|
6
|
+
- payload.message must be plain user-visible reminder text only.
|
|
7
|
+
- You must encode runtime guardrails directly into payload.message so the cron run can follow them without extra context.
|
|
8
|
+
- Runtime guardrails to encode in payload.message:
|
|
9
|
+
- return plain text only
|
|
10
|
+
- never call any tool
|
|
11
|
+
- never call the message tool
|
|
12
|
+
- never send manually; delivery is handled by cron delivery settings
|
|
13
|
+
- Do not include tool directives, "NO_REPLY", or heartbeat markers in payload.message.
|
|
14
|
+
- Job name is never a message target.
|
|
15
|
+
- During cron run, must return plain text only and never call the message tool.
|
|
16
|
+
- Use top-level delivery with announce mode:
|
|
17
17
|
delivery.mode="announce"
|
|
18
18
|
delivery.channel=<OriginatingChannel> (example: "qqbot")
|
|
19
19
|
delivery.to=<OriginatingTo> (examples: "user:<openid>" / "group:<group_openid>")
|