@invago/mixin 1.0.7 → 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/src/channel.ts CHANGED
@@ -1,16 +1,28 @@
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 {
4
+ buildChannelConfigSchema,
5
+ createDefaultChannelRuntimeState,
6
+ formatPairingApproveHint,
7
+ resolveChannelMediaMaxBytes,
8
+ } from "openclaw/plugin-sdk";
9
+ import type { ChannelGatewayContext, OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
3
10
  import { runBlazeLoop } from "./blaze-service.js";
4
11
  import { MixinConfigSchema } from "./config-schema.js";
5
- import { describeAccount, isConfigured, listAccountIds, resolveAccount } from "./config.js";
12
+ import { describeAccount, isConfigured, listAccountIds, resolveAccount, resolveDefaultAccountId, resolveMediaMaxMb } from "./config.js";
6
13
  import { handleMixinMessage, type MixinInboundMessage } from "./inbound-handler.js";
7
- import { sendTextMessage, startSendWorker } from "./send-service.js";
14
+ import { buildMixinOutboundPlanFromReplyPayload, executeMixinOutboundPlan } from "./outbound-plan.js";
15
+ import { getMixinRuntime } from "./runtime.js";
16
+ import { getOutboxStatus, sendAudioMessage, sendFileMessage, sendTextMessage, startSendWorker } from "./send-service.js";
17
+ import { buildMixinAccountSnapshot, buildMixinChannelSummary, resolveMixinStatusSnapshot } from "./status.js";
8
18
 
9
19
  type ResolvedMixinAccount = ReturnType<typeof resolveAccount>;
10
20
 
11
21
  const BASE_DELAY = 1000;
12
22
  const MAX_DELAY = 3000;
13
23
  const MULTIPLIER = 1.5;
24
+ const MEDIA_MAX_BYTES = 30 * 1024 * 1024;
25
+ const execFileAsync = promisify(execFile);
14
26
 
15
27
  async function sleep(ms: number): Promise<void> {
16
28
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -23,6 +35,128 @@ function maskKey(key: string): string {
23
35
  return key.slice(0, 4) + "****" + key.slice(-4);
24
36
  }
25
37
 
38
+ async function resolveAudioDurationSeconds(filePath: string): Promise<number | null> {
39
+ try {
40
+ const { stdout } = await execFileAsync(
41
+ process.platform === "win32" ? "ffprobe.exe" : "ffprobe",
42
+ [
43
+ "-v",
44
+ "error",
45
+ "-show_entries",
46
+ "format=duration",
47
+ "-of",
48
+ "default=noprint_wrappers=1:nokey=1",
49
+ filePath,
50
+ ],
51
+ { timeout: 15_000, windowsHide: true },
52
+ );
53
+ const seconds = Number.parseFloat(stdout.trim());
54
+ if (!Number.isFinite(seconds) || seconds <= 0) {
55
+ return null;
56
+ }
57
+ return Math.max(1, Math.ceil(seconds));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
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;
69
+ }
70
+
71
+ async function deliverOutboundMixinPayload(params: {
72
+ cfg: OpenClawConfig;
73
+ to: string;
74
+ text?: string;
75
+ mediaUrls?: string[];
76
+ mediaLocalRoots?: readonly string[];
77
+ accountId?: string | null;
78
+ }): Promise<{ channel: "mixin"; messageId: string }> {
79
+ const accountId = params.accountId ?? resolveDefaultAccountId(params.cfg);
80
+ const account = resolveAccount(params.cfg, accountId);
81
+ const mediaMaxBytes = resolveMixinMediaMaxBytes(params.cfg, accountId);
82
+ const runtime = getMixinRuntime();
83
+
84
+ const sendMediaUrl = async (mediaUrl: string): Promise<string | undefined> => {
85
+ const loaded = await runtime.media.loadWebMedia(mediaUrl, {
86
+ maxBytes: mediaMaxBytes,
87
+ localRoots: params.mediaLocalRoots,
88
+ });
89
+ const saved = await runtime.channel.media.saveMediaBuffer(
90
+ loaded.buffer,
91
+ loaded.contentType,
92
+ "mixin",
93
+ mediaMaxBytes,
94
+ loaded.fileName,
95
+ );
96
+
97
+ if (loaded.kind === "audio" && account.config.audioSendAsVoiceByDefault !== false) {
98
+ const duration = account.config.audioAutoDetectDuration === false
99
+ ? null
100
+ : await resolveAudioDurationSeconds(saved.path);
101
+ if (duration !== null) {
102
+ const audioResult = await sendAudioMessage(
103
+ params.cfg,
104
+ accountId,
105
+ params.to,
106
+ undefined,
107
+ {
108
+ filePath: saved.path,
109
+ mimeType: saved.contentType ?? loaded.contentType,
110
+ duration,
111
+ },
112
+ );
113
+ if (!audioResult.ok) {
114
+ throw new Error(audioResult.error ?? "mixin outbound audio send failed");
115
+ }
116
+ return audioResult.messageId;
117
+ }
118
+ if (account.config.audioRequireFfprobe) {
119
+ throw new Error("ffprobe is required to send mediaUrl audio as Mixin voice");
120
+ }
121
+ }
122
+
123
+ const fileResult = await sendFileMessage(
124
+ params.cfg,
125
+ accountId,
126
+ params.to,
127
+ undefined,
128
+ {
129
+ filePath: saved.path,
130
+ fileName: loaded.fileName,
131
+ mimeType: saved.contentType ?? loaded.contentType,
132
+ },
133
+ );
134
+ if (!fileResult.ok) {
135
+ throw new Error(fileResult.error ?? "mixin outbound file send failed");
136
+ }
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}`);
147
+ }
148
+
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 };
158
+ }
159
+
26
160
  export const mixinPlugin = {
27
161
  id: "mixin",
28
162
 
@@ -41,7 +175,7 @@ export const mixinPlugin = {
41
175
  chatTypes: ["direct", "group"] as Array<"direct" | "group">,
42
176
  reactions: false,
43
177
  threads: false,
44
- media: false,
178
+ media: true,
45
179
  nativeCommands: false,
46
180
  blockStreaming: false,
47
181
  },
@@ -50,27 +184,56 @@ export const mixinPlugin = {
50
184
  listAccountIds,
51
185
  resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
52
186
  resolveAccount(cfg, accountId ?? undefined),
53
- defaultAccountId: () => "default",
187
+ defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultAccountId(cfg),
188
+ },
189
+
190
+ pairing: {
191
+ idLabel: "Mixin UUID",
192
+ normalizeAllowEntry: (entry: string) => entry.trim().toLowerCase(),
54
193
  },
55
194
 
56
195
  security: {
57
196
  resolveDmPolicy: ({ account, accountId }: { account: ResolvedMixinAccount; accountId?: string | null }) => {
58
197
  const allowFrom = account.config.allowFrom ?? [];
59
198
  const basePath = accountId && accountId !== "default" ? `.accounts.${accountId}` : "";
199
+ const policy = account.config.dmPolicy ?? "pairing";
60
200
 
61
201
  return {
62
- policy: "allowlist" as const,
202
+ policy,
63
203
  allowFrom,
204
+ policyPath: `channels.mixin${basePath}.dmPolicy`,
64
205
  allowFromPath: `channels.mixin${basePath}.allowFrom`,
65
- approveHint: allowFrom.length > 0
66
- ? `已配置白名单用户数 ${allowFrom.length},将用户的 Mixin UUID 添加到 allowFrom 列表即可授权`
67
- : "将用户的 Mixin UUID 添加到 allowFrom 列表即可授权",
206
+ approveHint: policy === "pairing"
207
+ ? formatPairingApproveHint("mixin")
208
+ : allowFrom.length > 0
209
+ ? `已配置白名单用户数 ${allowFrom.length},将用户的 Mixin UUID 添加到 allowFrom 列表即可授权`
210
+ : "将用户的 Mixin UUID 添加到 allowFrom 列表即可授权",
68
211
  };
69
212
  },
70
213
  },
71
214
 
72
215
  outbound: {
73
216
  deliveryMode: "direct" as const,
217
+ textChunkLimit: 4000,
218
+ sendPayload: async (ctx: {
219
+ cfg: OpenClawConfig;
220
+ to: string;
221
+ payload: ReplyPayload;
222
+ mediaLocalRoots?: readonly string[];
223
+ accountId?: string | null;
224
+ }) =>
225
+ deliverOutboundMixinPayload({
226
+ cfg: ctx.cfg,
227
+ to: ctx.to,
228
+ text: ctx.payload.text,
229
+ mediaUrls: ctx.payload.mediaUrls && ctx.payload.mediaUrls.length > 0
230
+ ? ctx.payload.mediaUrls
231
+ : ctx.payload.mediaUrl
232
+ ? [ctx.payload.mediaUrl]
233
+ : [],
234
+ mediaLocalRoots: ctx.mediaLocalRoots,
235
+ accountId: ctx.accountId,
236
+ }),
74
237
 
75
238
  sendText: async (ctx: {
76
239
  cfg: OpenClawConfig;
@@ -78,13 +241,29 @@ export const mixinPlugin = {
78
241
  text: string;
79
242
  accountId?: string | null;
80
243
  }) => {
81
- const id = ctx.accountId ?? "default";
244
+ const id = ctx.accountId ?? resolveDefaultAccountId(ctx.cfg);
82
245
  const result = await sendTextMessage(ctx.cfg, id, ctx.to, undefined, ctx.text);
83
246
  if (result.ok) {
84
247
  return { channel: "mixin", messageId: result.messageId ?? ctx.to };
85
248
  }
86
249
  throw new Error(result.error ?? "sendText failed");
87
250
  },
251
+ sendMedia: async (ctx: {
252
+ cfg: OpenClawConfig;
253
+ to: string;
254
+ text: string;
255
+ mediaUrl?: string;
256
+ mediaLocalRoots?: readonly string[];
257
+ accountId?: string | null;
258
+ }) =>
259
+ deliverOutboundMixinPayload({
260
+ cfg: ctx.cfg,
261
+ to: ctx.to,
262
+ text: ctx.text,
263
+ mediaUrls: ctx.mediaUrl ? [ctx.mediaUrl] : [],
264
+ mediaLocalRoots: ctx.mediaLocalRoots,
265
+ accountId: ctx.accountId,
266
+ }),
88
267
  },
89
268
 
90
269
  gateway: {
@@ -99,6 +278,12 @@ export const mixinPlugin = {
99
278
  const config = account.config;
100
279
 
101
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
+ });
102
287
 
103
288
  let stopped = false;
104
289
  const stop = () => {
@@ -188,10 +373,43 @@ export const mixinPlugin = {
188
373
  },
189
374
 
190
375
  status: {
191
- defaultRuntime: {
192
- accountId: "default",
193
- running: false,
194
- 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
+ });
195
413
  },
196
414
  },
197
415
  };
@@ -1,3 +1,4 @@
1
+ import { DmPolicySchema, GroupPolicySchema } from "openclaw/plugin-sdk";
1
2
  import { z } from "zod";
2
3
 
3
4
  export const MixinProxyConfigSchema = z.object({
@@ -21,6 +22,16 @@ export const MixinProxyConfigSchema = z.object({
21
22
 
22
23
  export type MixinProxyConfig = z.infer<typeof MixinProxyConfigSchema>;
23
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
+
24
35
  export const MixinAccountConfigSchema = z.object({
25
36
  name: z.string().optional(),
26
37
  enabled: z.boolean().optional().default(true),
@@ -28,8 +39,17 @@ export const MixinAccountConfigSchema = z.object({
28
39
  sessionId: z.string().optional(),
29
40
  serverPublicKey: z.string().optional(),
30
41
  sessionPrivateKey: z.string().optional(),
42
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
31
43
  allowFrom: z.array(z.string()).optional().default([]),
44
+ groupPolicy: GroupPolicySchema.optional(),
45
+ groupAllowFrom: z.array(z.string()).optional(),
32
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(),
33
53
  debug: z.boolean().optional().default(false),
34
54
  proxy: MixinProxyConfigSchema.optional(),
35
55
  });
@@ -37,6 +57,7 @@ export const MixinAccountConfigSchema = z.object({
37
57
  export type MixinAccountConfig = z.infer<typeof MixinAccountConfigSchema>;
38
58
 
39
59
  export const MixinConfigSchema: z.ZodTypeAny = MixinAccountConfigSchema.extend({
60
+ defaultAccount: z.string().optional(),
40
61
  accounts: z.record(z.string(), MixinAccountConfigSchema.optional()).optional(),
41
62
  });
42
63
 
package/src/config.ts CHANGED
@@ -1,26 +1,60 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { MixinAccountConfigSchema, type MixinAccountConfig, type MixinConfig } from "./config-schema.js";
2
+ import {
3
+ MixinAccountConfigSchema,
4
+ MixinConversationConfigSchema,
5
+ type MixinAccountConfig,
6
+ type MixinConversationConfig,
7
+ } from "./config-schema.js";
3
8
 
4
- function getRawConfig(cfg: OpenClawConfig): any {
5
- return (cfg.channels as Record<string, unknown>)?.mixin ?? {};
9
+ type RawMixinConfig = Partial<MixinAccountConfig> & {
10
+ defaultAccount?: string;
11
+ accounts?: Record<string, Partial<MixinAccountConfig> | undefined>;
12
+ };
13
+
14
+ function getRawConfig(cfg: OpenClawConfig): RawMixinConfig {
15
+ return ((cfg.channels as Record<string, unknown>)?.mixin ?? {}) as RawMixinConfig;
6
16
  }
7
17
 
8
- export function listAccountIds(cfg: OpenClawConfig): string[] {
18
+ function hasTopLevelAccountConfig(raw: RawMixinConfig): boolean {
19
+ return Boolean(raw.appId || raw.sessionId || raw.serverPublicKey || raw.sessionPrivateKey || raw.name);
20
+ }
21
+
22
+ export function resolveDefaultAccountId(cfg: OpenClawConfig): string {
9
23
  const raw = getRawConfig(cfg);
24
+ const configuredDefault = raw.defaultAccount?.trim();
25
+ if (configuredDefault && raw.accounts?.[configuredDefault]) {
26
+ return configuredDefault;
27
+ }
28
+ if (configuredDefault === "default") {
29
+ return "default";
30
+ }
10
31
  if (raw.accounts && Object.keys(raw.accounts).length > 0) {
11
- return Object.keys(raw.accounts);
32
+ if (hasTopLevelAccountConfig(raw)) {
33
+ return "default";
34
+ }
35
+ return Object.keys(raw.accounts)[0] ?? "default";
12
36
  }
13
- return ["default"];
37
+ return "default";
38
+ }
39
+
40
+ export function listAccountIds(cfg: OpenClawConfig): string[] {
41
+ const raw = getRawConfig(cfg);
42
+ const accountIds = raw.accounts ? Object.keys(raw.accounts) : [];
43
+ if (hasTopLevelAccountConfig(raw) || accountIds.length === 0) {
44
+ return ["default", ...accountIds.filter((accountId) => accountId !== "default")];
45
+ }
46
+ return accountIds;
14
47
  }
15
48
 
16
49
  export function getAccountConfig(cfg: OpenClawConfig, accountId?: string): MixinAccountConfig {
17
50
  const raw = getRawConfig(cfg);
51
+ const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg);
18
52
  let accountRaw: Partial<MixinAccountConfig>;
19
53
 
20
- if (accountId && accountId !== "default" && raw.accounts?.[accountId]) {
21
- accountRaw = raw.accounts[accountId] as Partial<MixinAccountConfig>;
54
+ if (resolvedAccountId !== "default" && raw.accounts?.[resolvedAccountId]) {
55
+ accountRaw = raw.accounts[resolvedAccountId] as Partial<MixinAccountConfig>;
22
56
  } else {
23
- accountRaw = raw as MixinConfig as Partial<MixinAccountConfig>;
57
+ accountRaw = raw;
24
58
  }
25
59
 
26
60
  const result = MixinAccountConfigSchema.safeParse(accountRaw);
@@ -29,7 +63,7 @@ export function getAccountConfig(cfg: OpenClawConfig, accountId?: string): Mixin
29
63
  }
30
64
 
31
65
  export function resolveAccount(cfg: OpenClawConfig, accountId?: string) {
32
- const id = accountId ?? "default";
66
+ const id = accountId ?? resolveDefaultAccountId(cfg);
33
67
  const config = getAccountConfig(cfg, id);
34
68
  const configured = Boolean(config.appId && config.sessionId && config.serverPublicKey && config.sessionPrivateKey);
35
69
  return {
@@ -41,6 +75,7 @@ export function resolveAccount(cfg: OpenClawConfig, accountId?: string) {
41
75
  sessionId: config.sessionId,
42
76
  serverPublicKey: config.serverPublicKey,
43
77
  sessionPrivateKey: config.sessionPrivateKey,
78
+ dmPolicy: config.dmPolicy,
44
79
  allowFrom: config.allowFrom,
45
80
  requireMentionInGroup: config.requireMentionInGroup,
46
81
  debug: config.debug,
@@ -48,6 +83,60 @@ export function resolveAccount(cfg: OpenClawConfig, accountId?: string) {
48
83
  };
49
84
  }
50
85
 
86
+ export function resolveMediaMaxMb(cfg: OpenClawConfig, accountId?: string): number | undefined {
87
+ return getAccountConfig(cfg, accountId).mediaMaxMb;
88
+ }
89
+
90
+ function getRawAccountConfig(cfg: OpenClawConfig, accountId?: string): Partial<MixinAccountConfig> {
91
+ const raw = getRawConfig(cfg);
92
+ const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg);
93
+ if (resolvedAccountId !== "default" && raw.accounts?.[resolvedAccountId]) {
94
+ return raw.accounts[resolvedAccountId] as Partial<MixinAccountConfig>;
95
+ }
96
+ return raw;
97
+ }
98
+
99
+ export function getConversationConfig(
100
+ cfg: OpenClawConfig,
101
+ accountId: string,
102
+ conversationId: string,
103
+ ): {
104
+ exists: boolean;
105
+ config: MixinConversationConfig;
106
+ } {
107
+ const accountRaw = getRawAccountConfig(cfg, accountId);
108
+ const conversationRaw = accountRaw.conversations?.[conversationId] as Partial<MixinConversationConfig> | undefined;
109
+ const result = MixinConversationConfigSchema.safeParse(conversationRaw ?? {});
110
+ return {
111
+ exists: Boolean(conversationRaw),
112
+ config: result.success ? result.data : MixinConversationConfigSchema.parse({}),
113
+ };
114
+ }
115
+
116
+ export function resolveConversationPolicy(
117
+ cfg: OpenClawConfig,
118
+ accountId: string,
119
+ conversationId: string,
120
+ ): {
121
+ enabled: boolean;
122
+ requireMention: boolean;
123
+ mediaBypassMention: boolean;
124
+ groupPolicy: MixinAccountConfig["groupPolicy"];
125
+ groupAllowFrom: string[];
126
+ hasConversationOverride: boolean;
127
+ } {
128
+ const accountConfig = getAccountConfig(cfg, accountId);
129
+ const conversation = getConversationConfig(cfg, accountId, conversationId);
130
+ return {
131
+ enabled: conversation.config.enabled !== false,
132
+ requireMention: conversation.config.requireMention ?? accountConfig.requireMentionInGroup,
133
+ mediaBypassMention: conversation.config.mediaBypassMention ?? accountConfig.mediaBypassMentionInGroup,
134
+ groupPolicy: conversation.config.groupPolicy ?? accountConfig.groupPolicy,
135
+ groupAllowFrom: conversation.config.allowFrom ?? accountConfig.groupAllowFrom ?? [],
136
+ hasConversationOverride: conversation.exists,
137
+ };
138
+ }
139
+
51
140
  export function isConfigured(account: ReturnType<typeof resolveAccount>): boolean {
52
141
  return account.configured;
53
142
  }