@invago/mixin 1.0.7 → 1.0.8

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
@@ -69,6 +69,7 @@ Edit your `openclaw.json` file manually and add both the channel configuration a
69
69
  "sessionId": "YOUR_SESSION_ID",
70
70
  "serverPublicKey": "YOUR_SERVER_PUBLIC_KEY_BASE64",
71
71
  "sessionPrivateKey": "YOUR_SESSION_PRIVATE_KEY_BASE64",
72
+ "dmPolicy": "pairing",
72
73
  "allowFrom": ["AUTHORIZED_USER_UUID"],
73
74
  "proxy": {
74
75
  "enabled": true,
@@ -93,9 +94,32 @@ Notes:
93
94
 
94
95
  - `channels.mixin` configures the channel itself.
95
96
  - `plugins.allow` and `plugins.entries.mixin.enabled` are also required so OpenClaw loads this plugin.
96
- - This plugin currently uses `allowFrom` as its sender allowlist. Do not assume other generic OpenClaw DM policy fields apply here unless the plugin explicitly supports them.
97
+ - Mixin supports the standard OpenClaw direct-message policies. The recommended setting is `dmPolicy: "pairing"`.
98
+ - `allowFrom` remains useful for pre-authorized users or manual overrides. Pairing approvals are stored in OpenClaw's pairing allowlist store.
97
99
  - If `proxy.url` already contains credentials, `proxy.username` and `proxy.password` can be omitted.
98
100
 
101
+ ## Pairing
102
+
103
+ For private chats, the recommended mode is:
104
+
105
+ ```json
106
+ {
107
+ "channels": {
108
+ "mixin": {
109
+ "dmPolicy": "pairing"
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ Behavior:
116
+
117
+ - A new, unauthorized Mixin user gets an 8-character pairing code in the DM.
118
+ - Approve that user with `openclaw pairing approve mixin <code>`.
119
+ - Use `openclaw pairing list mixin` to inspect pending pairing requests.
120
+ - Once approved, the user is added to OpenClaw's pairing allowlist store for the `mixin` channel.
121
+ - `allowFrom` is still honored and can be used alongside pairing for users you want to pre-authorize.
122
+
99
123
  ## Avoid Cross-Channel Session Mixing
100
124
 
101
125
  Mixin group chats already stay isolated by channel, but direct-message sessions follow the OpenClaw `session.dmScope` policy. If you keep the default `main` scope, Mixin direct messages can share the same main session with other channels such as Feishu.
@@ -121,6 +145,7 @@ Use `per-account-channel-peer` instead if you run multiple Mixin accounts and wa
121
145
  | `sessionId` | Yes | - | Session UUID |
122
146
  | `serverPublicKey` | Yes | - | Server Public Key (Base64) |
123
147
  | `sessionPrivateKey` | Yes | - | Session Private Key (Ed25519 Base64) |
148
+ | `dmPolicy` | No | `pairing` | Direct-message policy: `pairing`, `allowlist`, `open`, or `disabled` |
124
149
  | `allowFrom` | No | `[]` | Authorized user UUID whitelist |
125
150
  | `requireMentionInGroup` | No | `true` | Require trigger words in group chats |
126
151
  | `debug` | No | `false` | Debug mode |
@@ -163,6 +188,31 @@ Plugin-specific command:
163
188
  - Pending messages survive plugin restarts.
164
189
  - Inbound Blaze messages are acknowledged before dispatch so Mixin receives a read receipt as early as possible.
165
190
 
191
+ ## Media Support
192
+
193
+ Current media behavior is split into outbound and inbound support:
194
+
195
+ - OpenClaw native outbound media is enabled through the channel `sendMedia` path.
196
+ - The plugin sends outbound audio as `PLAIN_AUDIO` when it can resolve the media as audio and detect duration.
197
+ - If audio duration cannot be detected, the plugin falls back to regular file attachment sending.
198
+ - Non-audio outbound media is sent as Mixin file attachments.
199
+ - If OpenClaw sends both caption text and media, the plugin sends the text first and then the file.
200
+ - Voice-bubble style outbound audio is currently intended for the explicit `mixin-audio` template path.
201
+ - 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.
203
+
204
+ Current limits:
205
+
206
+ - Outbound audio does not transcode automatically.
207
+ - `mixin-audio` still requires a prepared local file, explicit `duration`, and optional `waveForm`.
208
+ - OpenClaw native outbound audio depends on local `ffprobe` availability to detect duration.
209
+ - OpenClaw native `sendMedia` still does not generate `waveForm`, so explicit `mixin-audio` remains the most deterministic path for polished voice-message output.
210
+ - Whether the agent can summarize, transcribe, or reason over inbound files/audio depends on your OpenClaw media-understanding configuration.
211
+
212
+ Manual test guide:
213
+
214
+ - See [docs/media-testing.md](docs/media-testing.md) for ready-to-run prompts and expected results.
215
+
166
216
  ## Explicit Reply Templates
167
217
 
168
218
  When you want deterministic Mixin output instead of heuristic auto-selection, have the agent reply with exactly one fenced template block.
@@ -214,11 +264,39 @@ Card:
214
264
  ```
215
265
  ```
216
266
 
267
+ File:
268
+
269
+ ```text
270
+ ```mixin-file
271
+ {
272
+ "filePath": "/absolute/path/to/report.pdf",
273
+ "fileName": "report.pdf",
274
+ "mimeType": "application/pdf"
275
+ }
276
+ ```
277
+ ```
278
+
279
+ Audio:
280
+
281
+ ```text
282
+ ```mixin-audio
283
+ {
284
+ "filePath": "/absolute/path/to/voice.ogg",
285
+ "mimeType": "audio/ogg",
286
+ "duration": 12,
287
+ "waveForm": "AAMMQQ=="
288
+ }
289
+ ```
290
+ ```
291
+
217
292
  Rules:
218
293
 
219
294
  - Explicit templates take priority over automatic detection.
220
295
  - Replies containing tables or fenced code blocks are sent as `mixin-post` by default.
221
296
  - `mixin-buttons` and `mixin-card` accept JSON only.
297
+ - `mixin-file` and `mixin-audio` also accept JSON only.
298
+ - `mixin-audio` requires `duration` in seconds. `waveForm` is optional.
299
+ - `mixin-file` and `mixin-audio` require absolute local file paths on the machine where OpenClaw runs.
222
300
  - Button and card links must use `http://` or `https://`.
223
301
  - Mixin clients may require your target domains to be present in the bot app's `Resource Patterns` allowlist.
224
302
 
@@ -235,6 +313,7 @@ Rules:
235
313
  "sessionId": "...",
236
314
  "serverPublicKey": "...",
237
315
  "sessionPrivateKey": "...",
316
+ "dmPolicy": "pairing",
238
317
  "allowFrom": ["..."]
239
318
  },
240
319
  "bot2": {
@@ -260,14 +339,14 @@ Rules:
260
339
  |---------|---------------|
261
340
  | Plugin not loaded | Run `openclaw plugins list` and `openclaw plugins info mixin` |
262
341
  | Channel not starting | Verify `channels.mixin` exists and credentials are complete |
263
- | Not receiving messages | Check `allowFrom`, trigger words, and Blaze connectivity |
342
+ | Not receiving messages | Check pairing approval or `allowFrom`, trigger words, and Blaze connectivity |
264
343
  | Messages not sending | Check proxy reachability, outbox backlog, and `/mixin-outbox` output |
265
344
  | Repeated inbound pushes | Check Blaze connectivity and confirm ACK logs/behavior |
266
345
 
267
346
  ## Security Notes
268
347
 
269
348
  - Keep `sessionPrivateKey` private.
270
- - Use `allowFrom` in production.
349
+ - Use `dmPolicy: "pairing"` or a strict `allowFrom` list in production.
271
350
  - Outbox files contain pending message bodies, so do not expose the `data/` directory.
272
351
 
273
352
  ## Links
package/README.zh-CN.md CHANGED
@@ -70,6 +70,7 @@ openclaw plugins install .
70
70
  "sessionId": "你的 Session ID",
71
71
  "serverPublicKey": "服务端公钥 Base64",
72
72
  "sessionPrivateKey": "会话私钥 Base64",
73
+ "dmPolicy": "pairing",
73
74
  "allowFrom": ["授权用户 UUID"],
74
75
  "proxy": {
75
76
  "enabled": true,
@@ -94,9 +95,32 @@ openclaw plugins install .
94
95
 
95
96
  - `channels.mixin` 负责配置这个频道本身。
96
97
  - `plugins.allow` 和 `plugins.entries.mixin.enabled` 也需要配置,否则 OpenClaw 不会加载这个插件。
97
- - 当前插件使用 `allowFrom` 作为发送者白名单,不要直接套用其他 OpenClaw 通用 DM 策略字段,除非插件明确支持。
98
+ - Mixin 现在支持 OpenClaw 官方的私聊 `dmPolicy`,推荐使用 `dmPolicy: "pairing"`。
99
+ - `allowFrom` 仍然保留,适合预授权用户或人工补充白名单;配对批准结果会写入 OpenClaw 的 pairing allowlist store。
98
100
  - 如果 `proxy.url` 已经包含认证信息,可以不再填写 `proxy.username` 和 `proxy.password`。
99
101
 
102
+ ## 配对模式
103
+
104
+ 私聊推荐配置:
105
+
106
+ ```json
107
+ {
108
+ "channels": {
109
+ "mixin": {
110
+ "dmPolicy": "pairing"
111
+ }
112
+ }
113
+ }
114
+ ```
115
+
116
+ 行为说明:
117
+
118
+ - 未授权的私聊用户会先收到一个 8 位配对码。
119
+ - 管理员使用 `openclaw pairing approve mixin <code>` 完成批准。
120
+ - 使用 `openclaw pairing list mixin` 查看待批准的配对请求。
121
+ - 批准后,该用户会被加入 OpenClaw 的 `mixin` pairing allowlist store。
122
+ - `allowFrom` 仍然生效,可以和 pairing 一起使用。
123
+
100
124
  ## 避免跨通道串会话
101
125
 
102
126
  Mixin 群聊本身会按频道隔离,但私聊会话是否独立,取决于 OpenClaw 的 `session.dmScope` 配置。如果保持默认的 `main`,Mixin 私聊可能会和飞书等其它通道共用同一个主会话。
@@ -122,6 +146,7 @@ Mixin 群聊本身会按频道隔离,但私聊会话是否独立,取决于 O
122
146
  | `sessionId` | 是 | - | 会话 UUID |
123
147
  | `serverPublicKey` | 是 | - | 服务端公钥 Base64 |
124
148
  | `sessionPrivateKey` | 是 | - | 会话私钥 Ed25519 Base64 |
149
+ | `dmPolicy` | 否 | `pairing` | 私聊策略:`pairing`、`allowlist`、`open`、`disabled` |
125
150
  | `allowFrom` | 否 | `[]` | 授权用户 UUID 白名单 |
126
151
  | `requireMentionInGroup` | 否 | `true` | 群聊是否要求触发词 |
127
152
  | `debug` | 否 | `false` | 调试模式 |
@@ -164,6 +189,31 @@ openclaw status
164
189
  - 插件重启后,未完成的消息仍会继续补发。
165
190
  - 入站 Blaze 消息会在分发前尽快 ACK,尽量减少 Mixin 的重复推送。
166
191
 
192
+ ## 媒体支持现状
193
+
194
+ 当前媒体能力分为发送侧和接收侧:
195
+
196
+ - OpenClaw 原生媒体发送已经接入频道 `sendMedia`。
197
+ - 当插件能把媒体识别为音频并成功拿到时长时,会优先按 `PLAIN_AUDIO` 发送。
198
+ - 如果拿不到音频时长,会平稳降级为普通文件附件发送。
199
+ - 非音频媒体会按 Mixin 文件附件发送。
200
+ - 如果 OpenClaw 同时给出文本和媒体,插件会先发文本,再发文件。
201
+ - 语音气泡式发送目前仍更适合走显式 `mixin-audio` 模板。
202
+ - 入站 `PLAIN_DATA` 和 `PLAIN_AUDIO` 会被下载到本地,并通过 `MediaPath` / `MediaType` 挂到 OpenClaw 入站上下文。
203
+ - 即使启用了 `requireMentionInGroup`,群里的附件消息也不会再因为缺少文本触发词被直接过滤。
204
+
205
+ 当前边界:
206
+
207
+ - 发送语音时不做自动转码。
208
+ - `mixin-audio` 仍要求你提供已经准备好的本地文件,并显式给出 `duration`,`waveForm` 可选。
209
+ - OpenClaw 原生音频发送依赖本机可用的 `ffprobe` 来提取时长。
210
+ - OpenClaw 原生 `sendMedia` 仍不会自动生成 `waveForm`,所以如果你想更稳定地控制语音消息效果,显式 `mixin-audio` 仍然是最稳妥的路径。
211
+ - 是否能自动总结文件、转写语音,取决于你的 OpenClaw 媒体理解配置是否开启。
212
+
213
+ 联调手册:
214
+
215
+ - 见 [docs/media-testing.zh-CN.md](docs/media-testing.zh-CN.md)。
216
+
167
217
  ## 显式回复模板
168
218
 
169
219
  如果你希望 Mixin 回复严格按指定形式发送,而不是依赖自动判断,可以让 agent 只输出一个 fenced code block 模板。
@@ -215,11 +265,39 @@ openclaw status
215
265
  ```
216
266
  ```
217
267
 
268
+ 文件:
269
+
270
+ ```text
271
+ ```mixin-file
272
+ {
273
+ "filePath": "/absolute/path/to/report.pdf",
274
+ "fileName": "report.pdf",
275
+ "mimeType": "application/pdf"
276
+ }
277
+ ```
278
+ ```
279
+
280
+ 语音:
281
+
282
+ ```text
283
+ ```mixin-audio
284
+ {
285
+ "filePath": "/absolute/path/to/voice.ogg",
286
+ "mimeType": "audio/ogg",
287
+ "duration": 12,
288
+ "waveForm": "AAMMQQ=="
289
+ }
290
+ ```
291
+ ```
292
+
218
293
  规则:
219
294
 
220
295
  - 显式模板优先级高于自动识别。
221
296
  - 回复里只要出现表格或 fenced code block,默认就会走 `mixin-post` 长文。
222
297
  - `mixin-buttons` 和 `mixin-card` 只接受 JSON。
298
+ - `mixin-file` 和 `mixin-audio` 也只接受 JSON。
299
+ - `mixin-file` 和 `mixin-audio` 里的 `filePath` 必须是 OpenClaw 所在机器上的绝对路径。
300
+ - `mixin-audio` 里的 `duration` 必填,单位为秒,`waveForm` 可选。
223
301
  - 按钮和卡片链接必须使用 `http://` 或 `https://`。
224
302
  - Mixin 客户端可能要求目标域名已加入机器人应用的 `Resource Patterns` 白名单。
225
303
 
@@ -236,6 +314,7 @@ openclaw status
236
314
  "sessionId": "...",
237
315
  "serverPublicKey": "...",
238
316
  "sessionPrivateKey": "...",
317
+ "dmPolicy": "pairing",
239
318
  "allowFrom": ["..."]
240
319
  },
241
320
  "bot2": {
@@ -270,14 +349,14 @@ openclaw status
270
349
  | 插件未加载 | 运行 `openclaw plugins list` 和 `openclaw plugins info mixin` |
271
350
  | 频道未启动 | 检查 `channels.mixin` 是否存在,凭证是否完整 |
272
351
  | 插件未启用 | 检查 `plugins.allow` 和 `plugins.entries.mixin.enabled` |
273
- | 收不到消息 | 检查 `allowFrom`、触发词和 Blaze 连通性 |
352
+ | 收不到消息 | 检查 pairing 是否已批准或 `allowFrom` 是否包含该用户,同时检查触发词和 Blaze 连通性 |
274
353
  | 消息发不出去 | 检查代理是否可达、outbox 堆积情况和 `/mixin-outbox` 输出 |
275
354
  | 入站消息重复推送 | 检查 Blaze 连通性,并确认 ACK 是否正常发送 |
276
355
 
277
356
  ## 安全提示
278
357
 
279
358
  - 妥善保管 `sessionPrivateKey`
280
- - 生产环境务必配置 `allowFrom`
359
+ - 生产环境建议使用 `dmPolicy: "pairing"` 或严格的 `allowFrom`
281
360
  - outbox 文件会保存待发送消息正文,不要暴露 `data/` 目录
282
361
 
283
362
  ## 相关链接
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"@invago/mixin","version":"1.0.7","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.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"}}}
package/src/channel.ts CHANGED
@@ -1,16 +1,21 @@
1
- import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
2
- import type { ChannelGatewayContext, OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { buildChannelConfigSchema, formatPairingApproveHint } from "openclaw/plugin-sdk";
4
+ import type { ChannelGatewayContext, OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
3
5
  import { runBlazeLoop } from "./blaze-service.js";
4
6
  import { MixinConfigSchema } from "./config-schema.js";
5
7
  import { describeAccount, isConfigured, listAccountIds, resolveAccount } from "./config.js";
6
8
  import { handleMixinMessage, type MixinInboundMessage } from "./inbound-handler.js";
7
- import { sendTextMessage, startSendWorker } from "./send-service.js";
9
+ import { getMixinRuntime } from "./runtime.js";
10
+ import { sendAudioMessage, sendFileMessage, sendTextMessage, startSendWorker } from "./send-service.js";
8
11
 
9
12
  type ResolvedMixinAccount = ReturnType<typeof resolveAccount>;
10
13
 
11
14
  const BASE_DELAY = 1000;
12
15
  const MAX_DELAY = 3000;
13
16
  const MULTIPLIER = 1.5;
17
+ const MEDIA_MAX_BYTES = 30 * 1024 * 1024;
18
+ const execFileAsync = promisify(execFile);
14
19
 
15
20
  async function sleep(ms: number): Promise<void> {
16
21
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -23,6 +28,113 @@ function maskKey(key: string): string {
23
28
  return key.slice(0, 4) + "****" + key.slice(-4);
24
29
  }
25
30
 
31
+ async function resolveAudioDurationSeconds(filePath: string): Promise<number | null> {
32
+ try {
33
+ const { stdout } = await execFileAsync(
34
+ process.platform === "win32" ? "ffprobe.exe" : "ffprobe",
35
+ [
36
+ "-v",
37
+ "error",
38
+ "-show_entries",
39
+ "format=duration",
40
+ "-of",
41
+ "default=noprint_wrappers=1:nokey=1",
42
+ filePath,
43
+ ],
44
+ { timeout: 15_000, windowsHide: true },
45
+ );
46
+ const seconds = Number.parseFloat(stdout.trim());
47
+ if (!Number.isFinite(seconds) || seconds <= 0) {
48
+ return null;
49
+ }
50
+ return Math.max(1, Math.ceil(seconds));
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
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] : [];
61
+ }
62
+
63
+ async function deliverOutboundMixinPayload(params: {
64
+ cfg: OpenClawConfig;
65
+ to: string;
66
+ text?: string;
67
+ mediaUrls?: string[];
68
+ mediaLocalRoots?: readonly string[];
69
+ accountId?: string | null;
70
+ }): 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
+
82
+ const runtime = getMixinRuntime();
83
+ for (const mediaUrl of params.mediaUrls ?? []) {
84
+ const loaded = await runtime.media.loadWebMedia(mediaUrl, {
85
+ maxBytes: MEDIA_MAX_BYTES,
86
+ localRoots: params.mediaLocalRoots,
87
+ });
88
+ const saved = await runtime.channel.media.saveMediaBuffer(
89
+ loaded.buffer,
90
+ loaded.contentType,
91
+ "mixin",
92
+ MEDIA_MAX_BYTES,
93
+ loaded.fileName,
94
+ );
95
+
96
+ if (loaded.kind === "audio") {
97
+ const duration = await resolveAudioDurationSeconds(saved.path);
98
+ if (duration !== null) {
99
+ const audioResult = await sendAudioMessage(
100
+ params.cfg,
101
+ accountId,
102
+ params.to,
103
+ undefined,
104
+ {
105
+ filePath: saved.path,
106
+ mimeType: saved.contentType ?? loaded.contentType,
107
+ duration,
108
+ },
109
+ );
110
+ if (!audioResult.ok) {
111
+ throw new Error(audioResult.error ?? "mixin outbound audio send failed");
112
+ }
113
+ lastMessageId = audioResult.messageId ?? lastMessageId;
114
+ continue;
115
+ }
116
+ }
117
+
118
+ const fileResult = await sendFileMessage(
119
+ params.cfg,
120
+ accountId,
121
+ params.to,
122
+ undefined,
123
+ {
124
+ filePath: saved.path,
125
+ fileName: loaded.fileName,
126
+ mimeType: saved.contentType ?? loaded.contentType,
127
+ },
128
+ );
129
+ if (!fileResult.ok) {
130
+ throw new Error(fileResult.error ?? "mixin outbound file send failed");
131
+ }
132
+ lastMessageId = fileResult.messageId ?? lastMessageId;
133
+ }
134
+
135
+ return { channel: "mixin", messageId: lastMessageId };
136
+ }
137
+
26
138
  export const mixinPlugin = {
27
139
  id: "mixin",
28
140
 
@@ -41,7 +153,7 @@ export const mixinPlugin = {
41
153
  chatTypes: ["direct", "group"] as Array<"direct" | "group">,
42
154
  reactions: false,
43
155
  threads: false,
44
- media: false,
156
+ media: true,
45
157
  nativeCommands: false,
46
158
  blockStreaming: false,
47
159
  },
@@ -53,24 +165,48 @@ export const mixinPlugin = {
53
165
  defaultAccountId: () => "default",
54
166
  },
55
167
 
168
+ pairing: {
169
+ idLabel: "Mixin UUID",
170
+ normalizeAllowEntry: (entry: string) => entry.trim().toLowerCase(),
171
+ },
172
+
56
173
  security: {
57
174
  resolveDmPolicy: ({ account, accountId }: { account: ResolvedMixinAccount; accountId?: string | null }) => {
58
175
  const allowFrom = account.config.allowFrom ?? [];
59
176
  const basePath = accountId && accountId !== "default" ? `.accounts.${accountId}` : "";
177
+ const policy = account.config.dmPolicy ?? "pairing";
60
178
 
61
179
  return {
62
- policy: "allowlist" as const,
180
+ policy,
63
181
  allowFrom,
182
+ policyPath: `channels.mixin${basePath}.dmPolicy`,
64
183
  allowFromPath: `channels.mixin${basePath}.allowFrom`,
65
- approveHint: allowFrom.length > 0
66
- ? `已配置白名单用户数 ${allowFrom.length},将用户的 Mixin UUID 添加到 allowFrom 列表即可授权`
67
- : "将用户的 Mixin UUID 添加到 allowFrom 列表即可授权",
184
+ approveHint: policy === "pairing"
185
+ ? formatPairingApproveHint("mixin")
186
+ : allowFrom.length > 0
187
+ ? `已配置白名单用户数 ${allowFrom.length},将用户的 Mixin UUID 添加到 allowFrom 列表即可授权`
188
+ : "将用户的 Mixin UUID 添加到 allowFrom 列表即可授权",
68
189
  };
69
190
  },
70
191
  },
71
192
 
72
193
  outbound: {
73
194
  deliveryMode: "direct" as const,
195
+ sendPayload: async (ctx: {
196
+ cfg: OpenClawConfig;
197
+ to: string;
198
+ payload: ReplyPayload;
199
+ mediaLocalRoots?: readonly string[];
200
+ accountId?: string | null;
201
+ }) =>
202
+ deliverOutboundMixinPayload({
203
+ cfg: ctx.cfg,
204
+ to: ctx.to,
205
+ text: ctx.payload.text,
206
+ mediaUrls: resolvePayloadMediaUrls(ctx.payload),
207
+ mediaLocalRoots: ctx.mediaLocalRoots,
208
+ accountId: ctx.accountId,
209
+ }),
74
210
 
75
211
  sendText: async (ctx: {
76
212
  cfg: OpenClawConfig;
@@ -85,6 +221,22 @@ export const mixinPlugin = {
85
221
  }
86
222
  throw new Error(result.error ?? "sendText failed");
87
223
  },
224
+ sendMedia: async (ctx: {
225
+ cfg: OpenClawConfig;
226
+ to: string;
227
+ text: string;
228
+ mediaUrl?: string;
229
+ mediaLocalRoots?: readonly string[];
230
+ accountId?: string | null;
231
+ }) =>
232
+ deliverOutboundMixinPayload({
233
+ cfg: ctx.cfg,
234
+ to: ctx.to,
235
+ text: ctx.text,
236
+ mediaUrls: ctx.mediaUrl ? [ctx.mediaUrl] : [],
237
+ mediaLocalRoots: ctx.mediaLocalRoots,
238
+ accountId: ctx.accountId,
239
+ }),
88
240
  },
89
241
 
90
242
  gateway: {
@@ -1,3 +1,4 @@
1
+ import { DmPolicySchema } from "openclaw/plugin-sdk";
1
2
  import { z } from "zod";
2
3
 
3
4
  export const MixinProxyConfigSchema = z.object({
@@ -28,6 +29,7 @@ export const MixinAccountConfigSchema = z.object({
28
29
  sessionId: z.string().optional(),
29
30
  serverPublicKey: z.string().optional(),
30
31
  sessionPrivateKey: z.string().optional(),
32
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
31
33
  allowFrom: z.array(z.string()).optional().default([]),
32
34
  requireMentionInGroup: z.boolean().optional().default(true),
33
35
  debug: z.boolean().optional().default(false),
package/src/config.ts CHANGED
@@ -41,6 +41,7 @@ export function resolveAccount(cfg: OpenClawConfig, accountId?: string) {
41
41
  sessionId: config.sessionId,
42
42
  serverPublicKey: config.serverPublicKey,
43
43
  sessionPrivateKey: config.sessionPrivateKey,
44
+ dmPolicy: config.dmPolicy,
44
45
  allowFrom: config.allowFrom,
45
46
  requireMentionInGroup: config.requireMentionInGroup,
46
47
  debug: config.debug,