@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.
@@ -1,4 +1,4 @@
1
- import type { MixinButton, MixinCard } from "./send-service.js";
1
+ import type { MixinAudio, MixinButton, MixinCard, MixinFile } from "./send-service.js";
2
2
 
3
3
  type LinkItem = {
4
4
  label: string;
@@ -8,6 +8,8 @@ type LinkItem = {
8
8
  export type MixinReplyPlan =
9
9
  | { kind: "text"; text: string }
10
10
  | { kind: "post"; text: string }
11
+ | { kind: "file"; file: MixinFile }
12
+ | { kind: "audio"; audio: MixinAudio }
11
13
  | { kind: "buttons"; intro?: string; buttons: MixinButton[] }
12
14
  | { kind: "card"; card: MixinCard };
13
15
 
@@ -15,7 +17,7 @@ const MAX_BUTTONS = 6;
15
17
  const MAX_BUTTON_LABEL = 36;
16
18
  const MAX_CARD_TITLE = 36;
17
19
  const MAX_CARD_DESCRIPTION = 120;
18
- const TEMPLATE_REGEX = /^```mixin-(text|post|buttons|card)\s*\n([\s\S]*?)\n```$/i;
20
+ const TEMPLATE_REGEX = /^```mixin-(text|post|buttons|card|file|audio)\s*\n([\s\S]*?)\n```$/i;
19
21
 
20
22
  function truncate(value: string, limit: number): string {
21
23
  return value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 3))}...`;
@@ -121,6 +123,49 @@ function parseCardTemplate(body: string): MixinReplyPlan | null {
121
123
  };
122
124
  }
123
125
 
126
+ function parseFileTemplate(body: string): MixinReplyPlan | null {
127
+ const parsed = parseJsonTemplate<MixinFile>(body);
128
+ if (!parsed || typeof parsed.filePath !== "string") {
129
+ return null;
130
+ }
131
+
132
+ const filePath = normalizeWhitespace(parsed.filePath);
133
+ if (!filePath) {
134
+ return null;
135
+ }
136
+
137
+ return {
138
+ kind: "file",
139
+ file: {
140
+ filePath,
141
+ fileName: typeof parsed.fileName === "string" ? normalizeWhitespace(parsed.fileName) : undefined,
142
+ mimeType: typeof parsed.mimeType === "string" ? normalizeWhitespace(parsed.mimeType) : undefined,
143
+ },
144
+ };
145
+ }
146
+
147
+ function parseAudioTemplate(body: string): MixinReplyPlan | null {
148
+ const parsed = parseJsonTemplate<MixinAudio>(body);
149
+ if (!parsed || typeof parsed.filePath !== "string" || typeof parsed.duration !== "number") {
150
+ return null;
151
+ }
152
+
153
+ const filePath = normalizeWhitespace(parsed.filePath);
154
+ if (!filePath || !Number.isFinite(parsed.duration) || parsed.duration <= 0) {
155
+ return null;
156
+ }
157
+
158
+ return {
159
+ kind: "audio",
160
+ audio: {
161
+ filePath,
162
+ duration: parsed.duration,
163
+ mimeType: typeof parsed.mimeType === "string" ? normalizeWhitespace(parsed.mimeType) : undefined,
164
+ waveForm: typeof parsed.waveForm === "string" ? normalizeWhitespace(parsed.waveForm) : undefined,
165
+ },
166
+ };
167
+ }
168
+
124
169
  function parseExplicitTemplate(text: string): MixinReplyPlan | null {
125
170
  const match = text.match(TEMPLATE_REGEX);
126
171
  if (!match) {
@@ -146,6 +191,14 @@ function parseExplicitTemplate(text: string): MixinReplyPlan | null {
146
191
  return parseCardTemplate(body);
147
192
  }
148
193
 
194
+ if (templateType === "file") {
195
+ return parseFileTemplate(body);
196
+ }
197
+
198
+ if (templateType === "audio") {
199
+ return parseAudioTemplate(body);
200
+ }
201
+
149
202
  return null;
150
203
  }
151
204
 
@@ -1,18 +1,17 @@
1
+ import crypto from "crypto";
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
3
+ import os from "os";
4
+ import path from "path";
1
5
  import { MixinApi } from "@mixin.dev/mixin-node-sdk";
2
6
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
7
  import type { MixinAccountConfig } from "./config-schema.js";
4
8
  import { getAccountConfig } from "./config.js";
5
9
  import { buildRequestConfig } from "./proxy.js";
6
- import crypto from "crypto";
7
- import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
8
- import path from "path";
10
+ import { getMixinRuntime } from "./runtime.js";
9
11
 
10
12
  const BASE_DELAY = 1000;
11
13
  const MAX_DELAY = 60_000;
12
14
  const MULTIPLIER = 1.5;
13
- const OUTBOX_DIR = path.join(process.cwd(), "data");
14
- const OUTBOX_FILE = path.join(OUTBOX_DIR, "mixin-outbox.json");
15
- const OUTBOX_TMP_FILE = `${OUTBOX_FILE}.tmp`;
16
15
  const MAX_ERROR_LENGTH = 500;
17
16
  const MAX_OUTBOX_FILE_BYTES = 10 * 1024 * 1024;
18
17
 
@@ -25,6 +24,8 @@ type SendLog = {
25
24
  export type MixinSupportedMessageCategory =
26
25
  | "PLAIN_TEXT"
27
26
  | "PLAIN_POST"
27
+ | "PLAIN_AUDIO"
28
+ | "PLAIN_DATA"
28
29
  | "APP_BUTTON_GROUP"
29
30
  | "APP_CARD";
30
31
 
@@ -44,6 +45,34 @@ export interface MixinCard {
44
45
  shareable?: boolean;
45
46
  }
46
47
 
48
+ export interface MixinFile {
49
+ filePath: string;
50
+ fileName?: string;
51
+ mimeType?: string;
52
+ }
53
+
54
+ export interface MixinAudio {
55
+ filePath: string;
56
+ mimeType?: string;
57
+ duration: number;
58
+ waveForm?: string;
59
+ }
60
+
61
+ interface FileOutboxBody {
62
+ kind: "file";
63
+ filePath: string;
64
+ fileName: string;
65
+ mimeType: string;
66
+ }
67
+
68
+ interface AudioOutboxBody {
69
+ kind: "audio";
70
+ filePath: string;
71
+ mimeType: string;
72
+ duration: number;
73
+ waveForm?: string;
74
+ }
75
+
47
76
  interface OutboxEntry {
48
77
  jobId: string;
49
78
  accountId: string;
@@ -93,6 +122,7 @@ const state: {
93
122
  log: SendLog;
94
123
  loaded: boolean;
95
124
  started: boolean;
125
+ outboxPathLogged: boolean;
96
126
  entries: OutboxEntry[];
97
127
  persistChain: Promise<void>;
98
128
  wakeRequested: boolean;
@@ -102,6 +132,7 @@ const state: {
102
132
  log: fallbackLog,
103
133
  loaded: false,
104
134
  started: false,
135
+ outboxPathLogged: false,
105
136
  entries: [],
106
137
  persistChain: Promise.resolve(),
107
138
  wakeRequested: false,
@@ -124,6 +155,32 @@ function buildClient(config: MixinAccountConfig) {
124
155
  });
125
156
  }
126
157
 
158
+ function guessMimeType(fileName: string): string {
159
+ const ext = path.extname(fileName).toLowerCase();
160
+ switch (ext) {
161
+ case ".txt":
162
+ return "text/plain";
163
+ case ".md":
164
+ return "text/markdown";
165
+ case ".json":
166
+ return "application/json";
167
+ case ".pdf":
168
+ return "application/pdf";
169
+ case ".zip":
170
+ return "application/zip";
171
+ case ".csv":
172
+ return "text/csv";
173
+ case ".ogg":
174
+ return "audio/ogg";
175
+ case ".mp3":
176
+ return "audio/mpeg";
177
+ case ".wav":
178
+ return "audio/wav";
179
+ default:
180
+ return "application/octet-stream";
181
+ }
182
+ }
183
+
127
184
  function computeNextDelay(attempts: number): number {
128
185
  return Math.min(BASE_DELAY * Math.pow(MULTIPLIER, Math.max(0, attempts)), MAX_DELAY);
129
186
  }
@@ -135,6 +192,44 @@ function updateRuntime(cfg: OpenClawConfig, log?: SendLog): void {
135
192
  }
136
193
  }
137
194
 
195
+ function resolveFallbackOutboxDir(env: NodeJS.ProcessEnv = process.env): string {
196
+ const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
197
+ if (stateOverride) {
198
+ return path.join(stateOverride, "mixin");
199
+ }
200
+ const openClawHome = env.OPENCLAW_HOME?.trim();
201
+ if (openClawHome) {
202
+ return path.join(openClawHome, ".openclaw", "mixin");
203
+ }
204
+ return path.join(os.homedir(), ".openclaw", "mixin");
205
+ }
206
+
207
+ function resolveOutboxDir(): string {
208
+ try {
209
+ return path.join(getMixinRuntime().state.resolveStateDir(process.env, os.homedir), "mixin");
210
+ } catch (err) {
211
+ const fallbackDir = resolveFallbackOutboxDir();
212
+ state.log.warn(
213
+ `[mixin] failed to resolve OpenClaw state dir, falling back to ${fallbackDir}: ${err instanceof Error ? err.message : String(err)}`,
214
+ );
215
+ return fallbackDir;
216
+ }
217
+ }
218
+
219
+ function resolveOutboxPaths(): {
220
+ outboxDir: string;
221
+ outboxFile: string;
222
+ outboxTmpFile: string;
223
+ } {
224
+ const outboxDir = resolveOutboxDir();
225
+ const outboxFile = path.join(outboxDir, "mixin-outbox.json");
226
+ return {
227
+ outboxDir,
228
+ outboxFile,
229
+ outboxTmpFile: `${outboxFile}.tmp`,
230
+ };
231
+ }
232
+
138
233
  function normalizeErrorMessage(message: string): string {
139
234
  if (message.length <= MAX_ERROR_LENGTH) {
140
235
  return message;
@@ -171,17 +266,74 @@ function normalizeEntry(entry: OutboxEntry): OutboxEntry {
171
266
  };
172
267
  }
173
268
 
269
+ function isStructuredBody(body: string): body is string {
270
+ return body.trim().startsWith("{");
271
+ }
272
+
273
+ function parseFileBody(body: string): FileOutboxBody {
274
+ const parsed = JSON.parse(body) as Partial<FileOutboxBody>;
275
+ if (parsed.kind !== "file" || !parsed.filePath || !parsed.fileName || !parsed.mimeType) {
276
+ throw new Error("invalid file outbox body");
277
+ }
278
+ return {
279
+ kind: "file",
280
+ filePath: String(parsed.filePath),
281
+ fileName: String(parsed.fileName),
282
+ mimeType: String(parsed.mimeType),
283
+ };
284
+ }
285
+
286
+ async function buildAttachmentPayload(
287
+ client: ReturnType<typeof buildClient>,
288
+ filePath: string,
289
+ fileName: string,
290
+ mimeType: string,
291
+ ): Promise<string> {
292
+ const buffer = await readFile(filePath);
293
+ const file = new File([buffer], fileName, { type: mimeType });
294
+ const uploaded = await client.attachment.upload(file);
295
+ const fileInfo = await stat(filePath);
296
+
297
+ return JSON.stringify({
298
+ attachment_id: uploaded.attachment_id,
299
+ mime_type: mimeType,
300
+ size: fileInfo.size,
301
+ name: fileName,
302
+ });
303
+ }
304
+
305
+ async function buildAudioAttachmentPayload(
306
+ client: ReturnType<typeof buildClient>,
307
+ body: AudioOutboxBody,
308
+ ): Promise<string> {
309
+ const fileName = path.basename(body.filePath);
310
+ const buffer = await readFile(body.filePath);
311
+ const file = new File([buffer], fileName, { type: body.mimeType });
312
+ const uploaded = await client.attachment.upload(file);
313
+ const fileInfo = await stat(body.filePath);
314
+
315
+ return JSON.stringify({
316
+ attachment_id: uploaded.attachment_id,
317
+ mime_type: body.mimeType,
318
+ size: fileInfo.size,
319
+ duration: body.duration,
320
+ wave_form: body.waveForm,
321
+ });
322
+ }
323
+
174
324
  async function cleanupOutboxTmpFile(): Promise<void> {
325
+ const { outboxTmpFile } = resolveOutboxPaths();
175
326
  try {
176
- await rm(OUTBOX_TMP_FILE, { force: true });
327
+ await rm(outboxTmpFile, { force: true });
177
328
  } catch (err) {
178
329
  state.log.warn(`[mixin] failed to remove stale outbox tmp file: ${err instanceof Error ? err.message : String(err)}`);
179
330
  }
180
331
  }
181
332
 
182
333
  async function warnIfOutboxFileTooLarge(): Promise<void> {
334
+ const { outboxFile } = resolveOutboxPaths();
183
335
  try {
184
- const info = await stat(OUTBOX_FILE);
336
+ const info = await stat(outboxFile);
185
337
  if (info.size > MAX_OUTBOX_FILE_BYTES) {
186
338
  state.log.warn(`[mixin] outbox file is large: bytes=${info.size}, pending=${state.entries.length}`);
187
339
  }
@@ -198,11 +350,16 @@ async function ensureOutboxLoaded(): Promise<void> {
198
350
  return;
199
351
  }
200
352
 
201
- await mkdir(OUTBOX_DIR, { recursive: true });
353
+ const { outboxDir, outboxFile } = resolveOutboxPaths();
354
+ if (!state.outboxPathLogged) {
355
+ state.log.info(`[mixin] outbox path: dir=${outboxDir}, file=${outboxFile}`);
356
+ state.outboxPathLogged = true;
357
+ }
358
+ await mkdir(outboxDir, { recursive: true });
202
359
  await cleanupOutboxTmpFile();
203
360
 
204
361
  try {
205
- const raw = await readFile(OUTBOX_FILE, "utf-8");
362
+ const raw = await readFile(outboxFile, "utf-8");
206
363
  const parsed = JSON.parse(raw) as OutboxEntry[];
207
364
  state.entries = Array.isArray(parsed)
208
365
  ? parsed.map((entry) => normalizeEntry(entry))
@@ -228,10 +385,11 @@ function queuePersist(task: () => Promise<void>): Promise<void> {
228
385
 
229
386
  async function persistEntries(): Promise<void> {
230
387
  await queuePersist(async () => {
231
- await mkdir(OUTBOX_DIR, { recursive: true });
388
+ const { outboxDir, outboxFile, outboxTmpFile } = resolveOutboxPaths();
389
+ await mkdir(outboxDir, { recursive: true });
232
390
  const payload = JSON.stringify(state.entries, null, 2);
233
- await writeFile(OUTBOX_TMP_FILE, payload, "utf-8");
234
- await rename(OUTBOX_TMP_FILE, OUTBOX_FILE);
391
+ await writeFile(outboxTmpFile, payload, "utf-8");
392
+ await rename(outboxTmpFile, outboxFile);
235
393
  await warnIfOutboxFileTooLarge();
236
394
  });
237
395
  }
@@ -296,6 +454,16 @@ async function attemptSend(entry: OutboxEntry): Promise<void> {
296
454
  }
297
455
 
298
456
  const client = buildClient(config);
457
+ let payloadBody = entry.body;
458
+ if (entry.category === "PLAIN_DATA" && isStructuredBody(entry.body)) {
459
+ const body = parseFileBody(entry.body);
460
+ payloadBody = await buildAttachmentPayload(client, body.filePath, body.fileName, body.mimeType);
461
+ } else if (entry.category === "PLAIN_AUDIO" && isStructuredBody(entry.body)) {
462
+ const body = JSON.parse(entry.body) as AudioOutboxBody;
463
+ payloadBody = await buildAudioAttachmentPayload(client, body);
464
+ }
465
+
466
+ const dataBase64 = Buffer.from(payloadBody).toString("base64");
299
467
  const messagePayload: {
300
468
  conversation_id: string;
301
469
  message_id: string;
@@ -306,7 +474,7 @@ async function attemptSend(entry: OutboxEntry): Promise<void> {
306
474
  conversation_id: entry.conversationId,
307
475
  message_id: entry.messageId,
308
476
  category: entry.category,
309
- data_base64: Buffer.from(entry.body).toString("base64"),
477
+ data_base64: dataBase64,
310
478
  };
311
479
 
312
480
  if (entry.recipientId) {
@@ -461,6 +629,46 @@ export async function sendPostMessage(
461
629
  return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_POST", text, log);
462
630
  }
463
631
 
632
+ export async function sendFileMessage(
633
+ cfg: OpenClawConfig,
634
+ accountId: string,
635
+ conversationId: string,
636
+ recipientId: string | undefined,
637
+ file: MixinFile,
638
+ log?: SendLog,
639
+ ): Promise<SendResult> {
640
+ const fileName = file.fileName?.trim() || path.basename(file.filePath);
641
+ const mimeType = file.mimeType?.trim() || guessMimeType(fileName);
642
+ const body = JSON.stringify({
643
+ kind: "file",
644
+ filePath: file.filePath,
645
+ fileName,
646
+ mimeType,
647
+ } satisfies FileOutboxBody);
648
+
649
+ return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_DATA", body, log);
650
+ }
651
+
652
+ export async function sendAudioMessage(
653
+ cfg: OpenClawConfig,
654
+ accountId: string,
655
+ conversationId: string,
656
+ recipientId: string | undefined,
657
+ audio: MixinAudio,
658
+ log?: SendLog,
659
+ ): Promise<SendResult> {
660
+ const mimeType = audio.mimeType?.trim() || guessMimeType(audio.filePath);
661
+ const body = JSON.stringify({
662
+ kind: "audio",
663
+ filePath: audio.filePath,
664
+ mimeType,
665
+ duration: audio.duration,
666
+ waveForm: audio.waveForm,
667
+ } satisfies AudioOutboxBody);
668
+
669
+ return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_AUDIO", body, log);
670
+ }
671
+
464
672
  export async function sendButtonGroupMessage(
465
673
  cfg: OpenClawConfig,
466
674
  accountId: string,