@invago/mixin 1.0.8 → 1.0.9
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 +67 -1
- package/README.zh-CN.md +67 -1
- package/package.json +1 -1
- package/src/channel.ts +101 -35
- package/src/config-schema.ts +20 -1
- package/src/config.ts +98 -10
- package/src/inbound-handler.ts +108 -47
- package/src/outbound-plan.ts +197 -0
- package/src/reply-format.ts +37 -23
- package/src/send-service.ts +11 -0
- package/src/status.ts +100 -0
- package/tools/mixin-plugin-onboard/README.md +98 -0
- package/tools/mixin-plugin-onboard/bin/mixin-plugin-onboard.mjs +3 -0
- package/tools/mixin-plugin-onboard/src/commands/doctor.ts +28 -0
- package/tools/mixin-plugin-onboard/src/commands/info.ts +23 -0
- package/tools/mixin-plugin-onboard/src/commands/install.ts +5 -0
- package/tools/mixin-plugin-onboard/src/commands/update.ts +5 -0
- package/tools/mixin-plugin-onboard/src/index.ts +49 -0
- package/tools/mixin-plugin-onboard/src/utils.ts +189 -0
package/README.md
CHANGED
|
@@ -65,12 +65,19 @@ Edit your `openclaw.json` file manually and add both the channel configuration a
|
|
|
65
65
|
"channels": {
|
|
66
66
|
"mixin": {
|
|
67
67
|
"enabled": true,
|
|
68
|
+
"defaultAccount": "default",
|
|
68
69
|
"appId": "YOUR_APP_ID",
|
|
69
70
|
"sessionId": "YOUR_SESSION_ID",
|
|
70
71
|
"serverPublicKey": "YOUR_SERVER_PUBLIC_KEY_BASE64",
|
|
71
72
|
"sessionPrivateKey": "YOUR_SESSION_PRIVATE_KEY_BASE64",
|
|
72
73
|
"dmPolicy": "pairing",
|
|
73
74
|
"allowFrom": ["AUTHORIZED_USER_UUID"],
|
|
75
|
+
"requireMentionInGroup": true,
|
|
76
|
+
"mediaBypassMentionInGroup": true,
|
|
77
|
+
"mediaMaxMb": 30,
|
|
78
|
+
"audioSendAsVoiceByDefault": true,
|
|
79
|
+
"audioAutoDetectDuration": true,
|
|
80
|
+
"audioRequireFfprobe": false,
|
|
74
81
|
"proxy": {
|
|
75
82
|
"enabled": true,
|
|
76
83
|
"url": "socks5://127.0.0.1:10808",
|
|
@@ -141,13 +148,26 @@ Use `per-account-channel-peer` instead if you run multiple Mixin accounts and wa
|
|
|
141
148
|
| Parameter | Required | Default | Description |
|
|
142
149
|
|-----------|----------|---------|-------------|
|
|
143
150
|
| `enabled` | No | `true` | Enable or disable this channel account |
|
|
151
|
+
| `defaultAccount` | No | `default` | Default account ID used when `accounts` is configured |
|
|
144
152
|
| `appId` | Yes | - | Mixin App UUID |
|
|
145
153
|
| `sessionId` | Yes | - | Session UUID |
|
|
146
154
|
| `serverPublicKey` | Yes | - | Server Public Key (Base64) |
|
|
147
155
|
| `sessionPrivateKey` | Yes | - | Session Private Key (Ed25519 Base64) |
|
|
148
156
|
| `dmPolicy` | No | `pairing` | Direct-message policy: `pairing`, `allowlist`, `open`, or `disabled` |
|
|
149
157
|
| `allowFrom` | No | `[]` | Authorized user UUID whitelist |
|
|
158
|
+
| `groupPolicy` | No | OpenClaw default | Group-message policy: `open`, `allowlist`, or `disabled` |
|
|
159
|
+
| `groupAllowFrom` | No | `[]` | Authorized sender UUID whitelist for group messages when `groupPolicy` uses allowlisting |
|
|
150
160
|
| `requireMentionInGroup` | No | `true` | Require trigger words in group chats |
|
|
161
|
+
| `mediaBypassMentionInGroup` | No | `true` | Allow inbound group file/audio messages through even without trigger text |
|
|
162
|
+
| `mediaMaxMb` | No | `30` | Max inbound and outbound media size in MB |
|
|
163
|
+
| `audioSendAsVoiceByDefault` | No | `true` | Send OpenClaw native outbound audio as Mixin voice when possible |
|
|
164
|
+
| `audioAutoDetectDuration` | No | `true` | Detect native outbound audio duration with `ffprobe` before sending voice |
|
|
165
|
+
| `audioRequireFfprobe` | No | `false` | Fail native outbound audio instead of falling back to file when duration detection is unavailable |
|
|
166
|
+
| `conversations.<conversationId>.enabled` | No | `true` | Enable or disable a specific group conversation |
|
|
167
|
+
| `conversations.<conversationId>.requireMention` | No | Inherit account | Override group trigger-word requirement for a specific conversation |
|
|
168
|
+
| `conversations.<conversationId>.allowFrom` | No | Inherit account | Override group sender allowlist for a specific conversation |
|
|
169
|
+
| `conversations.<conversationId>.mediaBypassMention` | No | Inherit account | Override whether file/audio messages bypass mention filtering |
|
|
170
|
+
| `conversations.<conversationId>.groupPolicy` | No | Inherit account | Override group policy for a specific conversation |
|
|
151
171
|
| `debug` | No | `false` | Debug mode |
|
|
152
172
|
| `proxy.enabled` | No | `false` | Enable per-plugin proxy |
|
|
153
173
|
| `proxy.url` | Required when enabled | - | Proxy URL such as `http://127.0.0.1:7890` or `socks5://127.0.0.1:10808` |
|
|
@@ -160,6 +180,38 @@ Use `per-account-channel-peer` instead if you run multiple Mixin accounts and wa
|
|
|
160
180
|
- Supported proxy URL styles depend on the underlying proxy agent stack; typical values are `http://...`, `https://...`, and `socks5://...`.
|
|
161
181
|
- You must provide your own proxy software or proxy server. The plugin only consumes a proxy, it does not create one.
|
|
162
182
|
|
|
183
|
+
## Group Access Control
|
|
184
|
+
|
|
185
|
+
Mixin now supports formal group access controls in addition to direct-message `dmPolicy`.
|
|
186
|
+
|
|
187
|
+
- `groupPolicy: "open"` allows any sender in a group conversation.
|
|
188
|
+
- `groupPolicy: "allowlist"` requires the sender UUID to appear in `groupAllowFrom`.
|
|
189
|
+
- `groupPolicy: "disabled"` blocks the entire conversation.
|
|
190
|
+
- `conversations.<conversationId>` overrides account-level group settings for that single conversation.
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"channels": {
|
|
197
|
+
"mixin": {
|
|
198
|
+
"groupPolicy": "allowlist",
|
|
199
|
+
"groupAllowFrom": ["USER_A_UUID"],
|
|
200
|
+
"conversations": {
|
|
201
|
+
"70000000-0000-0000-0000-000000000001": {
|
|
202
|
+
"requireMention": false,
|
|
203
|
+
"allowFrom": ["USER_B_UUID"],
|
|
204
|
+
"mediaBypassMention": false
|
|
205
|
+
},
|
|
206
|
+
"70000000-0000-0000-0000-000000000002": {
|
|
207
|
+
"enabled": false
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
163
215
|
## Usage
|
|
164
216
|
|
|
165
217
|
- Direct message: `/status` or `Hello`
|
|
@@ -181,6 +233,18 @@ Plugin-specific command:
|
|
|
181
233
|
- Send `/mixin-outbox` to inspect the current pending queue size, next retry time, and latest error.
|
|
182
234
|
- Send `/mixin-outbox purge-invalid` to remove old `APP_CARD` / `APP_BUTTON_GROUP` entries that are stuck on permanent invalid-field errors.
|
|
183
235
|
|
|
236
|
+
Companion onboarding CLI:
|
|
237
|
+
|
|
238
|
+
- This repository also includes a companion CLI at [tools/mixin-plugin-onboard/README.md](/E:/AI/mixin-claw/tools/mixin-plugin-onboard/README.md).
|
|
239
|
+
- It is bundled into the same npm package, `@invago/mixin`.
|
|
240
|
+
- It currently provides `info`, `doctor`, `install`, and `update` commands for local OpenClaw + Mixin plugin maintenance.
|
|
241
|
+
- Local examples:
|
|
242
|
+
- `node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts info`
|
|
243
|
+
- `node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts doctor`
|
|
244
|
+
- Installed package examples:
|
|
245
|
+
- `npx -y @invago/mixin info`
|
|
246
|
+
- `npx -y @invago/mixin doctor`
|
|
247
|
+
|
|
184
248
|
## Delivery and Retry Behavior
|
|
185
249
|
|
|
186
250
|
- Outbound messages are persisted to a local outbox before send attempts.
|
|
@@ -193,13 +257,14 @@ Plugin-specific command:
|
|
|
193
257
|
Current media behavior is split into outbound and inbound support:
|
|
194
258
|
|
|
195
259
|
- OpenClaw native outbound media is enabled through the channel `sendMedia` path.
|
|
260
|
+
- OpenClaw native `sendPayload` now uses the same Mixin outbound planner as buffered agent replies, so text/post/buttons/card/file/audio selection is consistent.
|
|
196
261
|
- The plugin sends outbound audio as `PLAIN_AUDIO` when it can resolve the media as audio and detect duration.
|
|
197
262
|
- If audio duration cannot be detected, the plugin falls back to regular file attachment sending.
|
|
198
263
|
- Non-audio outbound media is sent as Mixin file attachments.
|
|
199
264
|
- If OpenClaw sends both caption text and media, the plugin sends the text first and then the file.
|
|
200
265
|
- Voice-bubble style outbound audio is currently intended for the explicit `mixin-audio` template path.
|
|
201
266
|
- Inbound `PLAIN_DATA` and `PLAIN_AUDIO` messages are downloaded, saved locally, and attached to the OpenClaw inbound context through `MediaPath` / `MediaType`.
|
|
202
|
-
- Group attachment messages are allowed through even when `requireMentionInGroup` is enabled
|
|
267
|
+
- Group attachment messages are allowed through even when `requireMentionInGroup` is enabled, unless `mediaBypassMentionInGroup` is set to `false`.
|
|
203
268
|
|
|
204
269
|
Current limits:
|
|
205
270
|
|
|
@@ -297,6 +362,7 @@ Rules:
|
|
|
297
362
|
- `mixin-file` and `mixin-audio` also accept JSON only.
|
|
298
363
|
- `mixin-audio` requires `duration` in seconds. `waveForm` is optional.
|
|
299
364
|
- `mixin-file` and `mixin-audio` require absolute local file paths on the machine where OpenClaw runs.
|
|
365
|
+
- Invalid explicit `mixin-*` templates are no longer dropped silently; the plugin now sends a visible `Mixin template error: ...` message instead.
|
|
300
366
|
- Button and card links must use `http://` or `https://`.
|
|
301
367
|
- Mixin clients may require your target domains to be present in the bot app's `Resource Patterns` allowlist.
|
|
302
368
|
|
package/README.zh-CN.md
CHANGED
|
@@ -66,12 +66,19 @@ openclaw plugins install .
|
|
|
66
66
|
"channels": {
|
|
67
67
|
"mixin": {
|
|
68
68
|
"enabled": true,
|
|
69
|
+
"defaultAccount": "default",
|
|
69
70
|
"appId": "你的 App ID",
|
|
70
71
|
"sessionId": "你的 Session ID",
|
|
71
72
|
"serverPublicKey": "服务端公钥 Base64",
|
|
72
73
|
"sessionPrivateKey": "会话私钥 Base64",
|
|
73
74
|
"dmPolicy": "pairing",
|
|
74
75
|
"allowFrom": ["授权用户 UUID"],
|
|
76
|
+
"requireMentionInGroup": true,
|
|
77
|
+
"mediaBypassMentionInGroup": true,
|
|
78
|
+
"mediaMaxMb": 30,
|
|
79
|
+
"audioSendAsVoiceByDefault": true,
|
|
80
|
+
"audioAutoDetectDuration": true,
|
|
81
|
+
"audioRequireFfprobe": false,
|
|
75
82
|
"proxy": {
|
|
76
83
|
"enabled": true,
|
|
77
84
|
"url": "socks5://127.0.0.1:10808",
|
|
@@ -142,13 +149,26 @@ Mixin 群聊本身会按频道隔离,但私聊会话是否独立,取决于 O
|
|
|
142
149
|
| 参数 | 必填 | 默认值 | 说明 |
|
|
143
150
|
|------|------|--------|------|
|
|
144
151
|
| `enabled` | 否 | `true` | 是否启用该频道账号 |
|
|
152
|
+
| `defaultAccount` | 否 | `default` | 配置了 `accounts` 时默认使用的账号 ID |
|
|
145
153
|
| `appId` | 是 | - | Mixin 应用 UUID |
|
|
146
154
|
| `sessionId` | 是 | - | 会话 UUID |
|
|
147
155
|
| `serverPublicKey` | 是 | - | 服务端公钥 Base64 |
|
|
148
156
|
| `sessionPrivateKey` | 是 | - | 会话私钥 Ed25519 Base64 |
|
|
149
157
|
| `dmPolicy` | 否 | `pairing` | 私聊策略:`pairing`、`allowlist`、`open`、`disabled` |
|
|
150
158
|
| `allowFrom` | 否 | `[]` | 授权用户 UUID 白名单 |
|
|
159
|
+
| `groupPolicy` | 否 | 跟随 OpenClaw 默认值 | 群消息策略:`open`、`allowlist`、`disabled` |
|
|
160
|
+
| `groupAllowFrom` | 否 | `[]` | 当 `groupPolicy` 走 allowlist 时,允许触发群消息的发送者 UUID 白名单 |
|
|
151
161
|
| `requireMentionInGroup` | 否 | `true` | 群聊是否要求触发词 |
|
|
162
|
+
| `mediaBypassMentionInGroup` | 否 | `true` | 群里的文件/语音消息是否可绕过文本触发词过滤 |
|
|
163
|
+
| `mediaMaxMb` | 否 | `30` | 入站和出站媒体大小上限,单位 MB |
|
|
164
|
+
| `audioSendAsVoiceByDefault` | 否 | `true` | OpenClaw 原生音频出站时尽量按 Mixin 语音发送 |
|
|
165
|
+
| `audioAutoDetectDuration` | 否 | `true` | 发送原生音频前是否用 `ffprobe` 自动探测时长 |
|
|
166
|
+
| `audioRequireFfprobe` | 否 | `false` | 时长探测不可用时是否直接失败,而不是降级为文件发送 |
|
|
167
|
+
| `conversations.<conversationId>.enabled` | 否 | `true` | 是否启用某个指定群会话 |
|
|
168
|
+
| `conversations.<conversationId>.requireMention` | 否 | 继承账号级配置 | 覆盖该群会话的触发词要求 |
|
|
169
|
+
| `conversations.<conversationId>.allowFrom` | 否 | 继承账号级配置 | 覆盖该群会话的发送者白名单 |
|
|
170
|
+
| `conversations.<conversationId>.mediaBypassMention` | 否 | 继承账号级配置 | 覆盖该群会话中文件/语音是否绕过触发词过滤 |
|
|
171
|
+
| `conversations.<conversationId>.groupPolicy` | 否 | 继承账号级配置 | 覆盖该群会话的群消息策略 |
|
|
152
172
|
| `debug` | 否 | `false` | 调试模式 |
|
|
153
173
|
| `proxy.enabled` | 否 | `false` | 是否启用插件级代理 |
|
|
154
174
|
| `proxy.url` | 启用时必填 | - | 代理地址,例如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:10808` |
|
|
@@ -161,6 +181,38 @@ Mixin 群聊本身会按频道隔离,但私聊会话是否独立,取决于 O
|
|
|
161
181
|
- 常见代理地址格式包括 `http://...`、`https://...`、`socks5://...`。
|
|
162
182
|
- 代理软件或代理服务器需要你自己提供,插件只负责使用代理。
|
|
163
183
|
|
|
184
|
+
## 群聊访问控制
|
|
185
|
+
|
|
186
|
+
现在除了私聊 `dmPolicy`,Mixin 也支持正式的群聊访问控制:
|
|
187
|
+
|
|
188
|
+
- `groupPolicy: "open"` 表示群里任何发送者都可以触发。
|
|
189
|
+
- `groupPolicy: "allowlist"` 表示只有 `groupAllowFrom` 里的发送者 UUID 可以触发。
|
|
190
|
+
- `groupPolicy: "disabled"` 表示整个群会话被禁用。
|
|
191
|
+
- `conversations.<conversationId>` 可以对某一个群会话覆盖账号级配置。
|
|
192
|
+
|
|
193
|
+
示例:
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"channels": {
|
|
198
|
+
"mixin": {
|
|
199
|
+
"groupPolicy": "allowlist",
|
|
200
|
+
"groupAllowFrom": ["USER_A_UUID"],
|
|
201
|
+
"conversations": {
|
|
202
|
+
"70000000-0000-0000-0000-000000000001": {
|
|
203
|
+
"requireMention": false,
|
|
204
|
+
"allowFrom": ["USER_B_UUID"],
|
|
205
|
+
"mediaBypassMention": false
|
|
206
|
+
},
|
|
207
|
+
"70000000-0000-0000-0000-000000000002": {
|
|
208
|
+
"enabled": false
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
164
216
|
## 使用方式
|
|
165
217
|
|
|
166
218
|
- 私聊:`/status` 或 `你好`
|
|
@@ -182,6 +234,18 @@ openclaw status
|
|
|
182
234
|
- 发送 `/mixin-outbox` 可查看当前待发队列数量、下次重试时间和最近错误。
|
|
183
235
|
- 发送 `/mixin-outbox purge-invalid` 可删除历史遗留的 `APP_CARD` / `APP_BUTTON_GROUP` 永久无效重试项。
|
|
184
236
|
|
|
237
|
+
配套运维 CLI:
|
|
238
|
+
|
|
239
|
+
- 仓库里已经附带了一套配套工具,见 [tools/mixin-plugin-onboard/README.md](/E:/AI/mixin-claw/tools/mixin-plugin-onboard/README.md)。
|
|
240
|
+
- 这套工具会和主包 `@invago/mixin` 一起发布,不再是单独第二个 npm 包。
|
|
241
|
+
- 当前提供 `info`、`doctor`、`install`、`update` 四个命令,用于本地 OpenClaw + Mixin 插件的安装和诊断。
|
|
242
|
+
- 本地运行示例:
|
|
243
|
+
- `node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts info`
|
|
244
|
+
- `node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts doctor`
|
|
245
|
+
- 安装后使用示例:
|
|
246
|
+
- `npx -y @invago/mixin info`
|
|
247
|
+
- `npx -y @invago/mixin doctor`
|
|
248
|
+
|
|
185
249
|
## 投递与重试行为
|
|
186
250
|
|
|
187
251
|
- 回复消息会先写入本地 outbox,再由后台 worker 发送。
|
|
@@ -194,13 +258,14 @@ openclaw status
|
|
|
194
258
|
当前媒体能力分为发送侧和接收侧:
|
|
195
259
|
|
|
196
260
|
- OpenClaw 原生媒体发送已经接入频道 `sendMedia`。
|
|
261
|
+
- OpenClaw 原生 `sendPayload` 现在也会复用同一套 Mixin 出站 planner,所以文本、长文、按钮、卡片、文件、语音的选择逻辑和 agent 缓冲回复保持一致。
|
|
197
262
|
- 当插件能把媒体识别为音频并成功拿到时长时,会优先按 `PLAIN_AUDIO` 发送。
|
|
198
263
|
- 如果拿不到音频时长,会平稳降级为普通文件附件发送。
|
|
199
264
|
- 非音频媒体会按 Mixin 文件附件发送。
|
|
200
265
|
- 如果 OpenClaw 同时给出文本和媒体,插件会先发文本,再发文件。
|
|
201
266
|
- 语音气泡式发送目前仍更适合走显式 `mixin-audio` 模板。
|
|
202
267
|
- 入站 `PLAIN_DATA` 和 `PLAIN_AUDIO` 会被下载到本地,并通过 `MediaPath` / `MediaType` 挂到 OpenClaw 入站上下文。
|
|
203
|
-
- 即使启用了 `requireMentionInGroup
|
|
268
|
+
- 即使启用了 `requireMentionInGroup`,群里的附件消息也不会再因为缺少文本触发词被直接过滤;如果你把 `mediaBypassMentionInGroup` 设为 `false`,则会恢复和普通文本相同的群聊触发规则。
|
|
204
269
|
|
|
205
270
|
当前边界:
|
|
206
271
|
|
|
@@ -298,6 +363,7 @@ openclaw status
|
|
|
298
363
|
- `mixin-file` 和 `mixin-audio` 也只接受 JSON。
|
|
299
364
|
- `mixin-file` 和 `mixin-audio` 里的 `filePath` 必须是 OpenClaw 所在机器上的绝对路径。
|
|
300
365
|
- `mixin-audio` 里的 `duration` 必填,单位为秒,`waveForm` 可选。
|
|
366
|
+
- 如果显式 `mixin-*` 模板写错,插件不再静默跳过,而会直接发出可见的 `Mixin template error: ...` 文本提示。
|
|
301
367
|
- 按钮和卡片链接必须使用 `http://` 或 `https://`。
|
|
302
368
|
- Mixin 客户端可能要求目标域名已加入机器人应用的 `Resource Patterns` 白名单。
|
|
303
369
|
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"@invago/mixin","version":"1.0.
|
|
1
|
+
{"name":"@invago/mixin","version":"1.0.9","description":"Mixin Messenger channel plugin for OpenClaw","type":"module","main":"index.ts","bin":{"mixin-plugin-onboard":"tools/mixin-plugin-onboard/bin/mixin-plugin-onboard.mjs"},"scripts":{"dev":"nodemon --exec \"node --import jiti/register index.ts\" --ext ts","lint":"eslint src/**/*.ts index.ts","typecheck":"tsc --noEmit","tool:info":"node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts info","tool:doctor":"node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts doctor"},"peerDependencies":{"openclaw":">=2026.2.0"},"dependencies":{"@mixin.dev/mixin-node-sdk":"^7.4.1","@noble/curves":"^2.0.1","@noble/hashes":"^2.0.1","axios":"^1.6.0","express":"^5.2.1","jiti":"^1.21.0","proxy-agent":"^6.5.0","ws":"^8.18.3","zod":"^4.3.6"},"devDependencies":{"@eslint/js":"^10.0.1","@types/node":"^20.0.0","eslint":"^10.0.3","globals":"^17.4.0","nodemon":"^3.0.0","typescript":"^5.3.0","typescript-eslint":"^8.56.1"},"keywords":["openclaw","mixin","messenger","plugin","channel"],"author":"invagao","license":"MIT","repository":{"type":"git","url":"git+https://github.com/invago/mixinclaw.git"},"openclaw":{"extensions":["./index.ts"],"channel":{"id":"mixin","label":"Mixin Messenger","selectionLabel":"Mixin Messenger (Blaze WebSocket)","docsPath":"/channels/mixin","order":70,"aliases":["mixin-messenger","mixin"],"quickstartAllowFrom":true},"install":{"npmSpec":"@invago/mixin","localPath":"extensions/mixin"}}}
|
package/src/channel.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildChannelConfigSchema,
|
|
5
|
+
createDefaultChannelRuntimeState,
|
|
6
|
+
formatPairingApproveHint,
|
|
7
|
+
resolveChannelMediaMaxBytes,
|
|
8
|
+
} from "openclaw/plugin-sdk";
|
|
4
9
|
import type { ChannelGatewayContext, OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
|
|
5
10
|
import { runBlazeLoop } from "./blaze-service.js";
|
|
6
11
|
import { MixinConfigSchema } from "./config-schema.js";
|
|
7
|
-
import { describeAccount, isConfigured, listAccountIds, resolveAccount } from "./config.js";
|
|
12
|
+
import { describeAccount, isConfigured, listAccountIds, resolveAccount, resolveDefaultAccountId, resolveMediaMaxMb } from "./config.js";
|
|
8
13
|
import { handleMixinMessage, type MixinInboundMessage } from "./inbound-handler.js";
|
|
14
|
+
import { buildMixinOutboundPlanFromReplyPayload, executeMixinOutboundPlan } from "./outbound-plan.js";
|
|
9
15
|
import { getMixinRuntime } from "./runtime.js";
|
|
10
|
-
import { sendAudioMessage, sendFileMessage, sendTextMessage, startSendWorker } from "./send-service.js";
|
|
16
|
+
import { getOutboxStatus, sendAudioMessage, sendFileMessage, sendTextMessage, startSendWorker } from "./send-service.js";
|
|
17
|
+
import { buildMixinAccountSnapshot, buildMixinChannelSummary, resolveMixinStatusSnapshot } from "./status.js";
|
|
11
18
|
|
|
12
19
|
type ResolvedMixinAccount = ReturnType<typeof resolveAccount>;
|
|
13
20
|
|
|
@@ -53,11 +60,12 @@ async function resolveAudioDurationSeconds(filePath: string): Promise<number | n
|
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
function resolveMixinMediaMaxBytes(cfg: OpenClawConfig, accountId?: string | null): number {
|
|
64
|
+
return resolveChannelMediaMaxBytes({
|
|
65
|
+
cfg,
|
|
66
|
+
resolveChannelLimitMb: ({ cfg, accountId }) => resolveMediaMaxMb(cfg, accountId),
|
|
67
|
+
accountId,
|
|
68
|
+
}) ?? MEDIA_MAX_BYTES;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
async function deliverOutboundMixinPayload(params: {
|
|
@@ -68,33 +76,28 @@ async function deliverOutboundMixinPayload(params: {
|
|
|
68
76
|
mediaLocalRoots?: readonly string[];
|
|
69
77
|
accountId?: string | null;
|
|
70
78
|
}): Promise<{ channel: "mixin"; messageId: string }> {
|
|
71
|
-
const accountId = params.accountId ??
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (params.text?.trim()) {
|
|
75
|
-
const textResult = await sendTextMessage(params.cfg, accountId, params.to, undefined, params.text);
|
|
76
|
-
if (!textResult.ok) {
|
|
77
|
-
throw new Error(textResult.error ?? "mixin outbound text send failed");
|
|
78
|
-
}
|
|
79
|
-
lastMessageId = textResult.messageId ?? lastMessageId;
|
|
80
|
-
}
|
|
81
|
-
|
|
79
|
+
const accountId = params.accountId ?? resolveDefaultAccountId(params.cfg);
|
|
80
|
+
const account = resolveAccount(params.cfg, accountId);
|
|
81
|
+
const mediaMaxBytes = resolveMixinMediaMaxBytes(params.cfg, accountId);
|
|
82
82
|
const runtime = getMixinRuntime();
|
|
83
|
-
|
|
83
|
+
|
|
84
|
+
const sendMediaUrl = async (mediaUrl: string): Promise<string | undefined> => {
|
|
84
85
|
const loaded = await runtime.media.loadWebMedia(mediaUrl, {
|
|
85
|
-
maxBytes:
|
|
86
|
+
maxBytes: mediaMaxBytes,
|
|
86
87
|
localRoots: params.mediaLocalRoots,
|
|
87
88
|
});
|
|
88
89
|
const saved = await runtime.channel.media.saveMediaBuffer(
|
|
89
90
|
loaded.buffer,
|
|
90
91
|
loaded.contentType,
|
|
91
92
|
"mixin",
|
|
92
|
-
|
|
93
|
+
mediaMaxBytes,
|
|
93
94
|
loaded.fileName,
|
|
94
95
|
);
|
|
95
96
|
|
|
96
|
-
if (loaded.kind === "audio") {
|
|
97
|
-
const duration =
|
|
97
|
+
if (loaded.kind === "audio" && account.config.audioSendAsVoiceByDefault !== false) {
|
|
98
|
+
const duration = account.config.audioAutoDetectDuration === false
|
|
99
|
+
? null
|
|
100
|
+
: await resolveAudioDurationSeconds(saved.path);
|
|
98
101
|
if (duration !== null) {
|
|
99
102
|
const audioResult = await sendAudioMessage(
|
|
100
103
|
params.cfg,
|
|
@@ -110,8 +113,10 @@ async function deliverOutboundMixinPayload(params: {
|
|
|
110
113
|
if (!audioResult.ok) {
|
|
111
114
|
throw new Error(audioResult.error ?? "mixin outbound audio send failed");
|
|
112
115
|
}
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
return audioResult.messageId;
|
|
117
|
+
}
|
|
118
|
+
if (account.config.audioRequireFfprobe) {
|
|
119
|
+
throw new Error("ffprobe is required to send mediaUrl audio as Mixin voice");
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
|
|
@@ -129,10 +134,27 @@ async function deliverOutboundMixinPayload(params: {
|
|
|
129
134
|
if (!fileResult.ok) {
|
|
130
135
|
throw new Error(fileResult.error ?? "mixin outbound file send failed");
|
|
131
136
|
}
|
|
132
|
-
|
|
137
|
+
return fileResult.messageId;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const payloadPlan = buildMixinOutboundPlanFromReplyPayload({
|
|
141
|
+
text: params.text,
|
|
142
|
+
mediaUrl: params.mediaUrls?.[0],
|
|
143
|
+
mediaUrls: params.mediaUrls,
|
|
144
|
+
} as ReplyPayload);
|
|
145
|
+
for (const warning of payloadPlan.warnings) {
|
|
146
|
+
console.warn(`[mixin] outbound plan warning: ${warning}`);
|
|
133
147
|
}
|
|
134
148
|
|
|
135
|
-
|
|
149
|
+
const lastMessageId = await executeMixinOutboundPlan({
|
|
150
|
+
cfg: params.cfg,
|
|
151
|
+
accountId,
|
|
152
|
+
conversationId: params.to,
|
|
153
|
+
steps: payloadPlan.steps,
|
|
154
|
+
sendMediaUrl,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return { channel: "mixin", messageId: lastMessageId ?? params.to };
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
export const mixinPlugin = {
|
|
@@ -162,7 +184,7 @@ export const mixinPlugin = {
|
|
|
162
184
|
listAccountIds,
|
|
163
185
|
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
|
164
186
|
resolveAccount(cfg, accountId ?? undefined),
|
|
165
|
-
defaultAccountId: () =>
|
|
187
|
+
defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultAccountId(cfg),
|
|
166
188
|
},
|
|
167
189
|
|
|
168
190
|
pairing: {
|
|
@@ -192,6 +214,7 @@ export const mixinPlugin = {
|
|
|
192
214
|
|
|
193
215
|
outbound: {
|
|
194
216
|
deliveryMode: "direct" as const,
|
|
217
|
+
textChunkLimit: 4000,
|
|
195
218
|
sendPayload: async (ctx: {
|
|
196
219
|
cfg: OpenClawConfig;
|
|
197
220
|
to: string;
|
|
@@ -203,7 +226,11 @@ export const mixinPlugin = {
|
|
|
203
226
|
cfg: ctx.cfg,
|
|
204
227
|
to: ctx.to,
|
|
205
228
|
text: ctx.payload.text,
|
|
206
|
-
mediaUrls:
|
|
229
|
+
mediaUrls: ctx.payload.mediaUrls && ctx.payload.mediaUrls.length > 0
|
|
230
|
+
? ctx.payload.mediaUrls
|
|
231
|
+
: ctx.payload.mediaUrl
|
|
232
|
+
? [ctx.payload.mediaUrl]
|
|
233
|
+
: [],
|
|
207
234
|
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
208
235
|
accountId: ctx.accountId,
|
|
209
236
|
}),
|
|
@@ -214,7 +241,7 @@ export const mixinPlugin = {
|
|
|
214
241
|
text: string;
|
|
215
242
|
accountId?: string | null;
|
|
216
243
|
}) => {
|
|
217
|
-
const id = ctx.accountId ??
|
|
244
|
+
const id = ctx.accountId ?? resolveDefaultAccountId(ctx.cfg);
|
|
218
245
|
const result = await sendTextMessage(ctx.cfg, id, ctx.to, undefined, ctx.text);
|
|
219
246
|
if (result.ok) {
|
|
220
247
|
return { channel: "mixin", messageId: result.messageId ?? ctx.to };
|
|
@@ -251,6 +278,12 @@ export const mixinPlugin = {
|
|
|
251
278
|
const config = account.config;
|
|
252
279
|
|
|
253
280
|
await startSendWorker(cfg, log);
|
|
281
|
+
const outboxStatus = await getOutboxStatus().catch(() => null);
|
|
282
|
+
const statusSnapshot = resolveMixinStatusSnapshot(cfg, accountId, outboxStatus);
|
|
283
|
+
ctx.setStatus({
|
|
284
|
+
accountId,
|
|
285
|
+
...statusSnapshot,
|
|
286
|
+
});
|
|
254
287
|
|
|
255
288
|
let stopped = false;
|
|
256
289
|
const stop = () => {
|
|
@@ -340,10 +373,43 @@ export const mixinPlugin = {
|
|
|
340
373
|
},
|
|
341
374
|
|
|
342
375
|
status: {
|
|
343
|
-
defaultRuntime:
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
376
|
+
defaultRuntime: createDefaultChannelRuntimeState("default"),
|
|
377
|
+
buildChannelSummary: (params: {
|
|
378
|
+
snapshot: {
|
|
379
|
+
configured?: boolean | null;
|
|
380
|
+
running?: boolean | null;
|
|
381
|
+
lastStartAt?: number | null;
|
|
382
|
+
lastStopAt?: number | null;
|
|
383
|
+
lastError?: string | null;
|
|
384
|
+
defaultAccountId?: string | null;
|
|
385
|
+
outboxDir?: string | null;
|
|
386
|
+
outboxFile?: string | null;
|
|
387
|
+
outboxPending?: number | null;
|
|
388
|
+
mediaMaxMb?: number | null;
|
|
389
|
+
};
|
|
390
|
+
}) => buildMixinChannelSummary({ snapshot: params.snapshot }),
|
|
391
|
+
buildAccountSnapshot: (params: {
|
|
392
|
+
account: ResolvedMixinAccount;
|
|
393
|
+
runtime?: {
|
|
394
|
+
running?: boolean | null;
|
|
395
|
+
lastStartAt?: number | null;
|
|
396
|
+
lastStopAt?: number | null;
|
|
397
|
+
lastError?: string | null;
|
|
398
|
+
lastInboundAt?: number | null;
|
|
399
|
+
lastOutboundAt?: number | null;
|
|
400
|
+
} | null;
|
|
401
|
+
probe?: unknown;
|
|
402
|
+
cfg: OpenClawConfig;
|
|
403
|
+
}) => {
|
|
404
|
+
const { account, runtime, probe, cfg } = params;
|
|
405
|
+
const statusSnapshot = resolveMixinStatusSnapshot(cfg, account.accountId);
|
|
406
|
+
return buildMixinAccountSnapshot({
|
|
407
|
+
account,
|
|
408
|
+
runtime,
|
|
409
|
+
probe,
|
|
410
|
+
defaultAccountId: statusSnapshot.defaultAccountId,
|
|
411
|
+
outboxPending: statusSnapshot.outboxPending,
|
|
412
|
+
});
|
|
347
413
|
},
|
|
348
414
|
},
|
|
349
415
|
};
|
package/src/config-schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DmPolicySchema } from "openclaw/plugin-sdk";
|
|
1
|
+
import { DmPolicySchema, GroupPolicySchema } from "openclaw/plugin-sdk";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
4
|
export const MixinProxyConfigSchema = z.object({
|
|
@@ -22,6 +22,16 @@ export const MixinProxyConfigSchema = z.object({
|
|
|
22
22
|
|
|
23
23
|
export type MixinProxyConfig = z.infer<typeof MixinProxyConfigSchema>;
|
|
24
24
|
|
|
25
|
+
export const MixinConversationConfigSchema = z.object({
|
|
26
|
+
enabled: z.boolean().optional(),
|
|
27
|
+
requireMention: z.boolean().optional(),
|
|
28
|
+
allowFrom: z.array(z.string()).optional(),
|
|
29
|
+
mediaBypassMention: z.boolean().optional(),
|
|
30
|
+
groupPolicy: GroupPolicySchema.optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type MixinConversationConfig = z.infer<typeof MixinConversationConfigSchema>;
|
|
34
|
+
|
|
25
35
|
export const MixinAccountConfigSchema = z.object({
|
|
26
36
|
name: z.string().optional(),
|
|
27
37
|
enabled: z.boolean().optional().default(true),
|
|
@@ -31,7 +41,15 @@ export const MixinAccountConfigSchema = z.object({
|
|
|
31
41
|
sessionPrivateKey: z.string().optional(),
|
|
32
42
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
33
43
|
allowFrom: z.array(z.string()).optional().default([]),
|
|
44
|
+
groupPolicy: GroupPolicySchema.optional(),
|
|
45
|
+
groupAllowFrom: z.array(z.string()).optional(),
|
|
34
46
|
requireMentionInGroup: z.boolean().optional().default(true),
|
|
47
|
+
mediaBypassMentionInGroup: z.boolean().optional().default(true),
|
|
48
|
+
mediaMaxMb: z.number().positive().optional(),
|
|
49
|
+
audioAutoDetectDuration: z.boolean().optional().default(true),
|
|
50
|
+
audioSendAsVoiceByDefault: z.boolean().optional().default(true),
|
|
51
|
+
audioRequireFfprobe: z.boolean().optional().default(false),
|
|
52
|
+
conversations: z.record(z.string(), MixinConversationConfigSchema.optional()).optional(),
|
|
35
53
|
debug: z.boolean().optional().default(false),
|
|
36
54
|
proxy: MixinProxyConfigSchema.optional(),
|
|
37
55
|
});
|
|
@@ -39,6 +57,7 @@ export const MixinAccountConfigSchema = z.object({
|
|
|
39
57
|
export type MixinAccountConfig = z.infer<typeof MixinAccountConfigSchema>;
|
|
40
58
|
|
|
41
59
|
export const MixinConfigSchema: z.ZodTypeAny = MixinAccountConfigSchema.extend({
|
|
60
|
+
defaultAccount: z.string().optional(),
|
|
42
61
|
accounts: z.record(z.string(), MixinAccountConfigSchema.optional()).optional(),
|
|
43
62
|
});
|
|
44
63
|
|