@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-china/shared",
3
- "version": "2026.3.8-3",
3
+ "version": "2026.3.9-1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -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
+ });
@@ -294,17 +294,25 @@ function getPreferredAccountConfig(channelCfg: ConfigRecord): ConfigRecord | und
294
294
  return undefined;
295
295
  }
296
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
- }
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
- case "wecom-app":
320
- return hasTokenPair(channelCfg);
321
- default:
322
- return false;
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
- 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
- }
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>")