@invago/mixin 1.0.9 → 1.0.10
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/README.md +262 -4
- package/README.zh-CN.md +328 -77
- package/package.json +79 -1
- package/src/blaze-service.ts +24 -7
- package/src/channel.ts +85 -8
- package/src/config-schema.ts +16 -0
- package/src/config.ts +5 -0
- package/src/crypto.ts +5 -0
- package/src/inbound-handler.ts +1205 -637
- package/src/mixpay-service.ts +211 -0
- package/src/mixpay-store.ts +205 -0
- package/src/mixpay-worker.ts +353 -0
- package/src/outbound-plan.ts +26 -7
- package/src/reply-format.ts +52 -1
- package/src/runtime.ts +26 -0
- package/src/send-service.ts +24 -27
- package/src/shared.ts +25 -0
- package/src/status.ts +14 -0
- package/src/decrypt.ts +0 -126
- package/tools/mixin-plugin-onboard/README.md +0 -98
- package/tools/mixin-plugin-onboard/bin/mixin-plugin-onboard.mjs +0 -3
- package/tools/mixin-plugin-onboard/src/commands/doctor.ts +0 -28
- package/tools/mixin-plugin-onboard/src/commands/info.ts +0 -23
- package/tools/mixin-plugin-onboard/src/commands/install.ts +0 -5
- package/tools/mixin-plugin-onboard/src/commands/update.ts +0 -5
- package/tools/mixin-plugin-onboard/src/index.ts +0 -49
- package/tools/mixin-plugin-onboard/src/utils.ts +0 -189
package/src/status.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary } from "o
|
|
|
2
2
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
3
|
import { getAccountConfig, resolveDefaultAccountId } from "./config.js";
|
|
4
4
|
import { getOutboxPathsSnapshot, type OutboxStatus } from "./send-service.js";
|
|
5
|
+
import type { getMixpayStatusSnapshot } from "./mixpay-worker.js";
|
|
5
6
|
|
|
6
7
|
type RuntimeLifecycleSnapshot = {
|
|
7
8
|
running?: boolean | null;
|
|
@@ -23,6 +24,9 @@ type MixinChannelStatusSnapshot = {
|
|
|
23
24
|
outboxFile?: string | null;
|
|
24
25
|
outboxPending?: number | null;
|
|
25
26
|
mediaMaxMb?: number | null;
|
|
27
|
+
mixpayPendingOrders?: number | null;
|
|
28
|
+
mixpayStoreDir?: string | null;
|
|
29
|
+
mixpayStoreFile?: string | null;
|
|
26
30
|
};
|
|
27
31
|
|
|
28
32
|
type MixinStatusAccount = {
|
|
@@ -44,12 +48,16 @@ export function resolveMixinStatusSnapshot(
|
|
|
44
48
|
cfg: OpenClawConfig,
|
|
45
49
|
accountId?: string,
|
|
46
50
|
outboxStatus?: OutboxStatus | null,
|
|
51
|
+
mixpayStatus?: Awaited<ReturnType<typeof getMixpayStatusSnapshot>> | null,
|
|
47
52
|
): {
|
|
48
53
|
defaultAccountId: string;
|
|
49
54
|
outboxDir: string;
|
|
50
55
|
outboxFile: string;
|
|
51
56
|
outboxPending: number;
|
|
52
57
|
mediaMaxMb: number | null;
|
|
58
|
+
mixpayPendingOrders: number;
|
|
59
|
+
mixpayStoreDir: string | null;
|
|
60
|
+
mixpayStoreFile: string | null;
|
|
53
61
|
} {
|
|
54
62
|
const defaultAccountId = resolveDefaultAccountId(cfg);
|
|
55
63
|
const resolvedAccountId = accountId ?? defaultAccountId;
|
|
@@ -61,6 +69,9 @@ export function resolveMixinStatusSnapshot(
|
|
|
61
69
|
outboxFile,
|
|
62
70
|
outboxPending: outboxStatus?.totalPending ?? 0,
|
|
63
71
|
mediaMaxMb: accountConfig.mediaMaxMb ?? null,
|
|
72
|
+
mixpayPendingOrders: mixpayStatus?.pendingOrders ?? 0,
|
|
73
|
+
mixpayStoreDir: mixpayStatus?.storeDir ?? null,
|
|
74
|
+
mixpayStoreFile: mixpayStatus?.storeFile ?? null,
|
|
64
75
|
};
|
|
65
76
|
}
|
|
66
77
|
|
|
@@ -75,6 +86,9 @@ export function buildMixinChannelSummary(params: {
|
|
|
75
86
|
outboxFile: snapshot.outboxFile ?? null,
|
|
76
87
|
outboxPending: snapshot.outboxPending ?? 0,
|
|
77
88
|
mediaMaxMb: snapshot.mediaMaxMb ?? null,
|
|
89
|
+
mixpayPendingOrders: snapshot.mixpayPendingOrders ?? 0,
|
|
90
|
+
mixpayStoreDir: snapshot.mixpayStoreDir ?? null,
|
|
91
|
+
mixpayStoreFile: snapshot.mixpayStoreFile ?? null,
|
|
78
92
|
};
|
|
79
93
|
}
|
|
80
94
|
|
package/src/decrypt.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 将 Mixin 的 Ed25519 seed 转换为 Curve25519 私钥,并与对端公钥协商出共享密钥
|
|
5
|
-
* @param seedHex 64 字符的 Hex 字符串,对应 session_private_key
|
|
6
|
-
* @param peerPublicKey 32 字节的对端 Curve25519 公钥
|
|
7
|
-
*/
|
|
8
|
-
export function x25519KeyAgreement(seedHex: string, peerPublicKey: Buffer): Buffer {
|
|
9
|
-
// 1. 将 64 字符的 Hex 转换为 32 字节的 seed
|
|
10
|
-
const seedBytes = Buffer.from(seedHex, 'hex');
|
|
11
|
-
if (seedBytes.length !== 32) {
|
|
12
|
-
throw new Error('Invalid Ed25519 seed length, expected 32 bytes.');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// 2. SHA-512 散列
|
|
16
|
-
const hash = crypto.createHash('sha512').update(seedBytes).digest();
|
|
17
|
-
|
|
18
|
-
// 3. 提取前 32 字节并进行 Curve25519 位截断 (Clamping)
|
|
19
|
-
const privateKeyX25519 = Buffer.from(hash.slice(0, 32));
|
|
20
|
-
privateKeyX25519[0] &= 248;
|
|
21
|
-
privateKeyX25519[31] &= 127;
|
|
22
|
-
privateKeyX25519[31] |= 64;
|
|
23
|
-
|
|
24
|
-
const ecdh = crypto.createECDH('x25519');
|
|
25
|
-
ecdh.setPrivateKey(privateKeyX25519);
|
|
26
|
-
|
|
27
|
-
return ecdh.computeSecret(peerPublicKey);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* 解密 Mixin ENCRYPTED_TEXT 消息 (对应 Go SDK DecryptMessageData)
|
|
32
|
-
* @param data Base64 编码的加密数据
|
|
33
|
-
* @param sessionId 机器人的 session_id
|
|
34
|
-
* @param privateKey 机器人的 ed25519 私钥(hex 格式,实为 seed)
|
|
35
|
-
* @returns 解密后的明文,失败返回 null
|
|
36
|
-
*/
|
|
37
|
-
export function decryptMessageData(
|
|
38
|
-
data: string,
|
|
39
|
-
sessionId: string,
|
|
40
|
-
privateKey: string
|
|
41
|
-
): string | null {
|
|
42
|
-
try {
|
|
43
|
-
// 1. Base64 解码,处理可能的 URL-safe Base64
|
|
44
|
-
let base64 = data.replace(/-/g, '+').replace(/_/g, '/');
|
|
45
|
-
while (base64.length % 4) {
|
|
46
|
-
base64 += '=';
|
|
47
|
-
}
|
|
48
|
-
const encryptedBytes = Buffer.from(base64, 'base64');
|
|
49
|
-
|
|
50
|
-
// 验证最小长度: version(1) + sessionCount(2) + senderPubKey(32) + nonce(12)
|
|
51
|
-
if (encryptedBytes.length < 1 + 2 + 32 + 12) {
|
|
52
|
-
console.error('[mixin decrypt] data too short:', encryptedBytes.length);
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// 解析消息结构
|
|
57
|
-
const version = encryptedBytes[0];
|
|
58
|
-
if (version !== 1) {
|
|
59
|
-
console.error('[mixin decrypt] unsupported version:', version);
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const sessionCount = encryptedBytes.readUInt16LE(1);
|
|
64
|
-
let offset = 3;
|
|
65
|
-
|
|
66
|
-
// 2. 提取发送者公钥 (已经是 Curve25519)
|
|
67
|
-
const senderPublicKey = encryptedBytes.slice(offset, offset + 32);
|
|
68
|
-
offset += 32;
|
|
69
|
-
|
|
70
|
-
// 查找匹配的 session
|
|
71
|
-
const sessionIdBuffer = Buffer.from(sessionId.replace(/-/g, ''), 'hex');
|
|
72
|
-
|
|
73
|
-
let sessionData: Buffer | null = null;
|
|
74
|
-
for (let i = 0; i < sessionCount; i++) {
|
|
75
|
-
const sessionIdInMsg = encryptedBytes.slice(offset, offset + 16);
|
|
76
|
-
|
|
77
|
-
if (sessionIdInMsg.equals(sessionIdBuffer)) {
|
|
78
|
-
sessionData = encryptedBytes.slice(offset + 16, offset + 64);
|
|
79
|
-
break; // 暂不中断读取,只取我们自己的 session 块
|
|
80
|
-
}
|
|
81
|
-
offset += 64;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (!sessionData) {
|
|
85
|
-
console.error('[mixin decrypt] session not found');
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 3. 计算 Shared Secret
|
|
90
|
-
const sharedSecret = x25519KeyAgreement(privateKey, senderPublicKey);
|
|
91
|
-
|
|
92
|
-
// 4. 解密 Message Key (AES-256-CBC)
|
|
93
|
-
// sessionData 的前 16 字节为 IV,后 32 字节为加密后的 key
|
|
94
|
-
const sessionIv = sessionData.slice(0, 16);
|
|
95
|
-
const encryptedKey = sessionData.slice(16, 48);
|
|
96
|
-
|
|
97
|
-
const decipherKey = crypto.createDecipheriv('aes-256-cbc', sharedSecret, sessionIv);
|
|
98
|
-
// Mixin SDK 这里加了 padding 处理。如果后续失败,尝试 decipherKey.setAutoPadding(false);
|
|
99
|
-
const rawMessageKey = Buffer.concat([decipherKey.update(encryptedKey), decipherKey.final()]);
|
|
100
|
-
|
|
101
|
-
// 取前 16 字节!
|
|
102
|
-
const messageKey = rawMessageKey.slice(0, 16);
|
|
103
|
-
|
|
104
|
-
// 5. 获取 Nonce 和 密文
|
|
105
|
-
const prefixSize = 3 + 32 + sessionCount * 64;
|
|
106
|
-
const nonce = encryptedBytes.slice(prefixSize, prefixSize + 12); // 注意这里是 12 字节!!!
|
|
107
|
-
const encryptedText = encryptedBytes.slice(prefixSize + 12);
|
|
108
|
-
|
|
109
|
-
// 6. 解密消息体 (AES-128-GCM)
|
|
110
|
-
// 对于 GCM,还需要分离出 authentication tag (后 16 字节)
|
|
111
|
-
const tag = encryptedText.slice(-16);
|
|
112
|
-
const ciphertext = encryptedText.slice(0, -16);
|
|
113
|
-
|
|
114
|
-
const decipherGcm = crypto.createDecipheriv('aes-128-gcm', messageKey, nonce);
|
|
115
|
-
decipherGcm.setAuthTag(tag);
|
|
116
|
-
|
|
117
|
-
let decryptedText = decipherGcm.update(ciphertext);
|
|
118
|
-
decryptedText = Buffer.concat([decryptedText, decipherGcm.final()]);
|
|
119
|
-
|
|
120
|
-
return decryptedText.toString('utf8');
|
|
121
|
-
|
|
122
|
-
} catch (error) {
|
|
123
|
-
console.error('[mixin decrypt] error:', error);
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
# Mixin Plugin Onboarding CLI
|
|
2
|
-
|
|
3
|
-
This CLI is bundled inside [`@invago/mixin`](https://www.npmjs.com/package/@invago/mixin). It helps inspect local OpenClaw installation state, verify key paths, and automate plugin install or update commands.
|
|
4
|
-
|
|
5
|
-
## Commands
|
|
6
|
-
|
|
7
|
-
### `info`
|
|
8
|
-
|
|
9
|
-
Prints the current local OpenClaw and Mixin plugin context:
|
|
10
|
-
|
|
11
|
-
- OpenClaw home, state, and extensions directories
|
|
12
|
-
- detected `openclaw.json` path
|
|
13
|
-
- detected Mixin plugin directories
|
|
14
|
-
- whether the plugin looks enabled in config
|
|
15
|
-
- current outbox path
|
|
16
|
-
- whether `ffprobe` is available
|
|
17
|
-
|
|
18
|
-
Run:
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
npx -y @invago/mixin info
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
### `doctor`
|
|
25
|
-
|
|
26
|
-
Runs a basic local diagnosis and returns a non-zero exit code when required checks fail.
|
|
27
|
-
|
|
28
|
-
Current checks:
|
|
29
|
-
|
|
30
|
-
- config file found
|
|
31
|
-
- `channels.mixin` present
|
|
32
|
-
- plugin enabled in config
|
|
33
|
-
- plugin installed in extensions
|
|
34
|
-
- outbox directory writable
|
|
35
|
-
- `ffprobe` available
|
|
36
|
-
|
|
37
|
-
It also reports leftover `.openclaw-install-stage-*` directories if any are detected.
|
|
38
|
-
|
|
39
|
-
Run:
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
npx -y @invago/mixin doctor
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
### `install`
|
|
46
|
-
|
|
47
|
-
Runs:
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
openclaw plugins install @invago/mixin
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
You can also pass a custom npm spec:
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
npx -y @invago/mixin install @invago/mixin@latest
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
### `update`
|
|
60
|
-
|
|
61
|
-
Runs:
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
openclaw plugins install @invago/mixin@latest
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Run:
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
npx -y @invago/mixin update
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
## Local Development
|
|
74
|
-
|
|
75
|
-
From this repository:
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts info
|
|
79
|
-
node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts doctor
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Or from the tool directory:
|
|
83
|
-
|
|
84
|
-
```bash
|
|
85
|
-
cd tools/mixin-plugin-onboard
|
|
86
|
-
npm run info
|
|
87
|
-
npm run doctor
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
## Publish
|
|
91
|
-
|
|
92
|
-
This CLI is published together with the main `@invago/mixin` package from the repository root.
|
|
93
|
-
|
|
94
|
-
## Notes
|
|
95
|
-
|
|
96
|
-
- This CLI is intentionally read-mostly right now.
|
|
97
|
-
- `install` and `update` delegate to the local `openclaw` command.
|
|
98
|
-
- `doctor` currently treats missing `ffprobe` as a failed check because native outbound audio-as-voice depends on it.
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { buildContext, checkWritableDir, findMixinPluginDirs, isPluginEnabled, readMixinConfig, runFfprobeCheck } from "../utils.ts";
|
|
2
|
-
|
|
3
|
-
export async function runDoctor(): Promise<number> {
|
|
4
|
-
const ctx = await buildContext();
|
|
5
|
-
const pluginDirs = await findMixinPluginDirs(ctx.extensionsDir);
|
|
6
|
-
const mixinConfig = readMixinConfig(ctx.config);
|
|
7
|
-
const checks = [
|
|
8
|
-
{ label: "config_found", ok: Boolean(ctx.configPath) },
|
|
9
|
-
{ label: "mixin_config_present", ok: Boolean(mixinConfig) },
|
|
10
|
-
{ label: "plugin_enabled", ok: isPluginEnabled(ctx.config) },
|
|
11
|
-
{ label: "plugin_installed", ok: pluginDirs.length > 0 },
|
|
12
|
-
{ label: "outbox_writable", ok: await checkWritableDir(ctx.outboxDir) },
|
|
13
|
-
{ label: "ffprobe_available", ok: runFfprobeCheck() },
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
const stageDirs = pluginDirs.filter((dir) => dir.includes(".openclaw-install-stage-"));
|
|
17
|
-
console.log(JSON.stringify({
|
|
18
|
-
ok: checks.every((item) => item.ok),
|
|
19
|
-
checks,
|
|
20
|
-
stageDirs,
|
|
21
|
-
pluginDirs,
|
|
22
|
-
configPath: ctx.configPath,
|
|
23
|
-
outboxDir: ctx.outboxDir,
|
|
24
|
-
outboxFile: ctx.outboxFile,
|
|
25
|
-
}, null, 2));
|
|
26
|
-
|
|
27
|
-
return checks.every((item) => item.ok) ? 0 : 1;
|
|
28
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { buildContext, findMixinPluginDirs, isPluginEnabled, readMixinConfig, runFfprobeCheck } from "../utils.ts";
|
|
2
|
-
|
|
3
|
-
export async function runInfo(): Promise<number> {
|
|
4
|
-
const ctx = await buildContext();
|
|
5
|
-
const pluginDirs = await findMixinPluginDirs(ctx.extensionsDir);
|
|
6
|
-
const mixinConfig = readMixinConfig(ctx.config);
|
|
7
|
-
|
|
8
|
-
console.log(JSON.stringify({
|
|
9
|
-
homeDir: ctx.homeDir,
|
|
10
|
-
stateDir: ctx.stateDir,
|
|
11
|
-
extensionsDir: ctx.extensionsDir,
|
|
12
|
-
configPath: ctx.configPath,
|
|
13
|
-
pluginDirs,
|
|
14
|
-
pluginEnabled: isPluginEnabled(ctx.config),
|
|
15
|
-
mixinConfigured: Boolean(mixinConfig),
|
|
16
|
-
defaultAccount: typeof mixinConfig?.defaultAccount === "string" ? mixinConfig.defaultAccount : "default",
|
|
17
|
-
outboxDir: ctx.outboxDir,
|
|
18
|
-
outboxFile: ctx.outboxFile,
|
|
19
|
-
ffprobeAvailable: runFfprobeCheck(),
|
|
20
|
-
}, null, 2));
|
|
21
|
-
|
|
22
|
-
return 0;
|
|
23
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { runDoctor } from "./commands/doctor.ts";
|
|
2
|
-
import { runInfo } from "./commands/info.ts";
|
|
3
|
-
import { runInstall } from "./commands/install.ts";
|
|
4
|
-
import { runUpdate } from "./commands/update.ts";
|
|
5
|
-
|
|
6
|
-
function printUsage(): void {
|
|
7
|
-
console.log(`mixin-plugin-onboard <command>
|
|
8
|
-
|
|
9
|
-
Commands:
|
|
10
|
-
info
|
|
11
|
-
doctor
|
|
12
|
-
install [npm-spec]
|
|
13
|
-
update [npm-spec]
|
|
14
|
-
`);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async function main(): Promise<void> {
|
|
18
|
-
const [, , command, arg] = process.argv;
|
|
19
|
-
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
20
|
-
printUsage();
|
|
21
|
-
process.exitCode = 0;
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (command === "info") {
|
|
26
|
-
process.exitCode = await runInfo();
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (command === "doctor") {
|
|
31
|
-
process.exitCode = await runDoctor();
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (command === "install") {
|
|
36
|
-
process.exitCode = await runInstall(arg);
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (command === "update") {
|
|
41
|
-
process.exitCode = await runUpdate(arg);
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
printUsage();
|
|
46
|
-
process.exitCode = 1;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
await main();
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
-
|
|
6
|
-
export type OpenClawContext = {
|
|
7
|
-
homeDir: string;
|
|
8
|
-
stateDir: string;
|
|
9
|
-
extensionsDir: string;
|
|
10
|
-
configPath: string | null;
|
|
11
|
-
config: Record<string, unknown> | null;
|
|
12
|
-
outboxDir: string;
|
|
13
|
-
outboxFile: string;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export function resolveHomeDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
-
const configured = env.OPENCLAW_HOME?.trim();
|
|
18
|
-
if (configured) {
|
|
19
|
-
return configured;
|
|
20
|
-
}
|
|
21
|
-
return path.join(os.homedir(), ".openclaw");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
25
|
-
const configured = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
|
26
|
-
if (configured) {
|
|
27
|
-
return configured;
|
|
28
|
-
}
|
|
29
|
-
return path.join(resolveHomeDir(env), "state");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function resolveExtensionsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
33
|
-
return path.join(resolveHomeDir(env), "extensions");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function resolveOutboxPaths(env: NodeJS.ProcessEnv = process.env): {
|
|
37
|
-
outboxDir: string;
|
|
38
|
-
outboxFile: string;
|
|
39
|
-
} {
|
|
40
|
-
const outboxDir = path.join(resolveStateDir(env), "mixin");
|
|
41
|
-
return {
|
|
42
|
-
outboxDir,
|
|
43
|
-
outboxFile: path.join(outboxDir, "mixin-outbox.json"),
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function readConfig(env: NodeJS.ProcessEnv = process.env): Promise<{
|
|
48
|
-
path: string | null;
|
|
49
|
-
config: Record<string, unknown> | null;
|
|
50
|
-
}> {
|
|
51
|
-
const explicit = env.OPENCLAW_CONFIG?.trim();
|
|
52
|
-
const candidates = [
|
|
53
|
-
explicit,
|
|
54
|
-
path.join(resolveHomeDir(env), "openclaw.json"),
|
|
55
|
-
path.join(process.cwd(), "openclaw.json"),
|
|
56
|
-
].filter((value): value is string => Boolean(value));
|
|
57
|
-
|
|
58
|
-
for (const candidate of candidates) {
|
|
59
|
-
try {
|
|
60
|
-
const raw = await fs.readFile(candidate, "utf8");
|
|
61
|
-
return {
|
|
62
|
-
path: candidate,
|
|
63
|
-
config: parseLooseConfig(raw),
|
|
64
|
-
};
|
|
65
|
-
} catch {
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
path: null,
|
|
72
|
-
config: null,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function parseLooseConfig(raw: string): Record<string, unknown> {
|
|
77
|
-
try {
|
|
78
|
-
return JSON.parse(raw) as Record<string, unknown>;
|
|
79
|
-
} catch {
|
|
80
|
-
const relaxed = raw
|
|
81
|
-
.replace(/^\uFEFF/, "")
|
|
82
|
-
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
83
|
-
.replace(/^\s*\/\/.*$/gm, "")
|
|
84
|
-
.replace(/,\s*([}\]])/g, "$1");
|
|
85
|
-
return JSON.parse(relaxed) as Record<string, unknown>;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export async function buildContext(env: NodeJS.ProcessEnv = process.env): Promise<OpenClawContext> {
|
|
90
|
-
const config = await readConfig(env);
|
|
91
|
-
const outbox = resolveOutboxPaths(env);
|
|
92
|
-
return {
|
|
93
|
-
homeDir: resolveHomeDir(env),
|
|
94
|
-
stateDir: resolveStateDir(env),
|
|
95
|
-
extensionsDir: resolveExtensionsDir(env),
|
|
96
|
-
configPath: config.path,
|
|
97
|
-
config: config.config,
|
|
98
|
-
outboxDir: outbox.outboxDir,
|
|
99
|
-
outboxFile: outbox.outboxFile,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export async function findMixinPluginDirs(extensionsDir: string): Promise<string[]> {
|
|
104
|
-
try {
|
|
105
|
-
const entries = await fs.readdir(extensionsDir, { withFileTypes: true });
|
|
106
|
-
const matched: string[] = [];
|
|
107
|
-
for (const entry of entries) {
|
|
108
|
-
if (!entry.isDirectory()) {
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
const dirPath = path.join(extensionsDir, entry.name);
|
|
112
|
-
const openclawPluginPath = path.join(dirPath, "openclaw.plugin.json");
|
|
113
|
-
const packageJsonPath = path.join(dirPath, "package.json");
|
|
114
|
-
try {
|
|
115
|
-
const pluginRaw = await fs.readFile(openclawPluginPath, "utf8");
|
|
116
|
-
if (pluginRaw.includes("\"mixin\"")) {
|
|
117
|
-
matched.push(dirPath);
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
} catch {
|
|
121
|
-
}
|
|
122
|
-
try {
|
|
123
|
-
const packageRaw = await fs.readFile(packageJsonPath, "utf8");
|
|
124
|
-
if (packageRaw.includes("\"@invago/mixin\"") || packageRaw.includes("\"id\":\"mixin\"")) {
|
|
125
|
-
matched.push(dirPath);
|
|
126
|
-
}
|
|
127
|
-
} catch {
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return matched;
|
|
131
|
-
} catch {
|
|
132
|
-
return [];
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export async function checkWritableDir(dirPath: string): Promise<boolean> {
|
|
137
|
-
try {
|
|
138
|
-
await fs.mkdir(dirPath, { recursive: true });
|
|
139
|
-
const testPath = path.join(dirPath, `.write-test-${Date.now()}`);
|
|
140
|
-
await fs.writeFile(testPath, "ok", "utf8");
|
|
141
|
-
await fs.rm(testPath, { force: true });
|
|
142
|
-
return true;
|
|
143
|
-
} catch {
|
|
144
|
-
return false;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function runOpenClawInstall(spec: string): number {
|
|
149
|
-
const result = spawnSync("openclaw", ["plugins", "install", spec], {
|
|
150
|
-
stdio: "inherit",
|
|
151
|
-
shell: process.platform === "win32",
|
|
152
|
-
});
|
|
153
|
-
return result.status ?? 1;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function runFfprobeCheck(): boolean {
|
|
157
|
-
const result = spawnSync(process.platform === "win32" ? "ffprobe.exe" : "ffprobe", ["-version"], {
|
|
158
|
-
stdio: "ignore",
|
|
159
|
-
shell: false,
|
|
160
|
-
});
|
|
161
|
-
return result.status === 0;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function readMixinConfig(config: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
165
|
-
const channels = config?.channels;
|
|
166
|
-
if (!channels || typeof channels !== "object") {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
const mixin = (channels as Record<string, unknown>).mixin;
|
|
170
|
-
return mixin && typeof mixin === "object" ? (mixin as Record<string, unknown>) : null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export function isPluginEnabled(config: Record<string, unknown> | null): boolean {
|
|
174
|
-
const plugins = config?.plugins;
|
|
175
|
-
if (!plugins || typeof plugins !== "object") {
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
const allow = Array.isArray((plugins as Record<string, unknown>).allow)
|
|
179
|
-
? ((plugins as Record<string, unknown>).allow as unknown[]).map(String)
|
|
180
|
-
: [];
|
|
181
|
-
const entries = (plugins as Record<string, unknown>).entries;
|
|
182
|
-
const mixinEntry = entries && typeof entries === "object"
|
|
183
|
-
? (entries as Record<string, unknown>).mixin
|
|
184
|
-
: null;
|
|
185
|
-
const enabled = mixinEntry && typeof mixinEntry === "object"
|
|
186
|
-
? (mixinEntry as Record<string, unknown>).enabled !== false
|
|
187
|
-
: false;
|
|
188
|
-
return allow.includes("mixin") && enabled;
|
|
189
|
-
}
|