@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 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.8","description":"Mixin Messenger channel plugin for OpenClaw","type":"module","main":"index.ts","scripts":{"dev":"nodemon --exec \"node --import jiti/register index.ts\" --ext ts","lint":"eslint src/**/*.ts index.ts","typecheck":"tsc --noEmit"},"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","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","jiti":"^1.21.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"}}}
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 { buildChannelConfigSchema, formatPairingApproveHint } from "openclaw/plugin-sdk";
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 resolvePayloadMediaUrls(payload: ReplyPayload): string[] {
57
- if (payload.mediaUrls && payload.mediaUrls.length > 0) {
58
- return payload.mediaUrls;
59
- }
60
- return payload.mediaUrl ? [payload.mediaUrl] : [];
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 ?? "default";
72
- let lastMessageId = params.to;
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
- for (const mediaUrl of params.mediaUrls ?? []) {
83
+
84
+ const sendMediaUrl = async (mediaUrl: string): Promise<string | undefined> => {
84
85
  const loaded = await runtime.media.loadWebMedia(mediaUrl, {
85
- maxBytes: MEDIA_MAX_BYTES,
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
- MEDIA_MAX_BYTES,
93
+ mediaMaxBytes,
93
94
  loaded.fileName,
94
95
  );
95
96
 
96
- if (loaded.kind === "audio") {
97
- const duration = await resolveAudioDurationSeconds(saved.path);
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
- lastMessageId = audioResult.messageId ?? lastMessageId;
114
- continue;
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
- lastMessageId = fileResult.messageId ?? lastMessageId;
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
- return { channel: "mixin", messageId: lastMessageId };
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: () => "default",
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: resolvePayloadMediaUrls(ctx.payload),
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 ?? "default";
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
- accountId: "default",
345
- running: false,
346
- status: "stopped" as const,
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
  };
@@ -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