@invago/mixin 1.0.7

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.
@@ -0,0 +1,553 @@
1
+ import { MixinApi } from "@mixin.dev/mixin-node-sdk";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { MixinAccountConfig } from "./config-schema.js";
4
+ import { getAccountConfig } from "./config.js";
5
+ 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";
9
+
10
+ const BASE_DELAY = 1000;
11
+ const MAX_DELAY = 60_000;
12
+ 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
+ const MAX_ERROR_LENGTH = 500;
17
+ const MAX_OUTBOX_FILE_BYTES = 10 * 1024 * 1024;
18
+
19
+ type SendLog = {
20
+ info: (msg: string) => void;
21
+ error: (msg: string, err?: unknown) => void;
22
+ warn: (msg: string) => void;
23
+ };
24
+
25
+ export type MixinSupportedMessageCategory =
26
+ | "PLAIN_TEXT"
27
+ | "PLAIN_POST"
28
+ | "APP_BUTTON_GROUP"
29
+ | "APP_CARD";
30
+
31
+ export interface MixinButton {
32
+ label: string;
33
+ color?: string;
34
+ action: string;
35
+ }
36
+
37
+ export interface MixinCard {
38
+ title: string;
39
+ description: string;
40
+ action?: string;
41
+ actions?: MixinButton[];
42
+ coverUrl?: string;
43
+ iconUrl?: string;
44
+ shareable?: boolean;
45
+ }
46
+
47
+ interface OutboxEntry {
48
+ jobId: string;
49
+ accountId: string;
50
+ conversationId: string;
51
+ recipientId?: string;
52
+ category: MixinSupportedMessageCategory;
53
+ body: string;
54
+ messageId: string;
55
+ attempts: number;
56
+ nextAttemptAt: number;
57
+ createdAt: string;
58
+ updatedAt: string;
59
+ lastError?: string;
60
+ status: "pending" | "sending";
61
+ }
62
+
63
+ export interface OutboxStatus {
64
+ totalPending: number;
65
+ pendingByAccount: Array<{
66
+ accountId: string;
67
+ pending: number;
68
+ }>;
69
+ oldestPendingAt?: string;
70
+ nextAttemptAt?: string;
71
+ latestError?: string;
72
+ }
73
+
74
+ export interface OutboxPurgeResult {
75
+ removed: number;
76
+ removedJobIds: string[];
77
+ }
78
+
79
+ export interface SendResult {
80
+ ok: boolean;
81
+ messageId?: string;
82
+ error?: string;
83
+ }
84
+
85
+ const fallbackLog: SendLog = {
86
+ info: (msg: string) => console.log(msg),
87
+ warn: (msg: string) => console.warn(msg),
88
+ error: (msg: string, err?: unknown) => console.error(msg, err),
89
+ };
90
+
91
+ const state: {
92
+ cfg: OpenClawConfig | null;
93
+ log: SendLog;
94
+ loaded: boolean;
95
+ started: boolean;
96
+ entries: OutboxEntry[];
97
+ persistChain: Promise<void>;
98
+ wakeRequested: boolean;
99
+ wakeResolver: (() => void) | null;
100
+ } = {
101
+ cfg: null,
102
+ log: fallbackLog,
103
+ loaded: false,
104
+ started: false,
105
+ entries: [],
106
+ persistChain: Promise.resolve(),
107
+ wakeRequested: false,
108
+ wakeResolver: null,
109
+ };
110
+
111
+ function sleep(ms: number): Promise<void> {
112
+ return new Promise((resolve) => setTimeout(resolve, ms));
113
+ }
114
+
115
+ function buildClient(config: MixinAccountConfig) {
116
+ return MixinApi({
117
+ keystore: {
118
+ app_id: config.appId!,
119
+ session_id: config.sessionId!,
120
+ server_public_key: config.serverPublicKey!,
121
+ session_private_key: config.sessionPrivateKey!,
122
+ },
123
+ requestConfig: buildRequestConfig(config.proxy),
124
+ });
125
+ }
126
+
127
+ function computeNextDelay(attempts: number): number {
128
+ return Math.min(BASE_DELAY * Math.pow(MULTIPLIER, Math.max(0, attempts)), MAX_DELAY);
129
+ }
130
+
131
+ function updateRuntime(cfg: OpenClawConfig, log?: SendLog): void {
132
+ state.cfg = cfg;
133
+ if (log) {
134
+ state.log = log;
135
+ }
136
+ }
137
+
138
+ function normalizeErrorMessage(message: string): string {
139
+ if (message.length <= MAX_ERROR_LENGTH) {
140
+ return message;
141
+ }
142
+ return `${message.slice(0, MAX_ERROR_LENGTH)}...`;
143
+ }
144
+
145
+ function isPermanentInvalidEntry(entry: OutboxEntry): boolean {
146
+ if (entry.category !== "APP_CARD" && entry.category !== "APP_BUTTON_GROUP") {
147
+ return false;
148
+ }
149
+
150
+ const error = (entry.lastError ?? "").toLowerCase();
151
+ return error.includes("code: 10002") && error.includes("invalid field");
152
+ }
153
+
154
+ function normalizeEntry(entry: OutboxEntry): OutboxEntry {
155
+ const legacyText = "text" in entry && typeof (entry as OutboxEntry & { text?: unknown }).text === "string"
156
+ ? String((entry as OutboxEntry & { text?: unknown }).text)
157
+ : "";
158
+ const category = typeof entry.category === "string" ? entry.category : "PLAIN_TEXT";
159
+ const body = typeof entry.body === "string" ? entry.body : legacyText;
160
+
161
+ return {
162
+ ...entry,
163
+ category,
164
+ body,
165
+ attempts: typeof entry.attempts === "number" ? entry.attempts : 0,
166
+ nextAttemptAt: typeof entry.nextAttemptAt === "number" ? entry.nextAttemptAt : Date.now(),
167
+ updatedAt: entry.updatedAt ?? entry.createdAt ?? new Date().toISOString(),
168
+ createdAt: entry.createdAt ?? new Date().toISOString(),
169
+ status: "pending",
170
+ lastError: entry.lastError ? normalizeErrorMessage(entry.lastError) : undefined,
171
+ };
172
+ }
173
+
174
+ async function cleanupOutboxTmpFile(): Promise<void> {
175
+ try {
176
+ await rm(OUTBOX_TMP_FILE, { force: true });
177
+ } catch (err) {
178
+ state.log.warn(`[mixin] failed to remove stale outbox tmp file: ${err instanceof Error ? err.message : String(err)}`);
179
+ }
180
+ }
181
+
182
+ async function warnIfOutboxFileTooLarge(): Promise<void> {
183
+ try {
184
+ const info = await stat(OUTBOX_FILE);
185
+ if (info.size > MAX_OUTBOX_FILE_BYTES) {
186
+ state.log.warn(`[mixin] outbox file is large: bytes=${info.size}, pending=${state.entries.length}`);
187
+ }
188
+ } catch (err) {
189
+ const code = err && typeof err === "object" && "code" in err ? String((err as { code?: string }).code) : "";
190
+ if (code !== "ENOENT") {
191
+ state.log.warn(`[mixin] failed to stat outbox file: ${err instanceof Error ? err.message : String(err)}`);
192
+ }
193
+ }
194
+ }
195
+
196
+ async function ensureOutboxLoaded(): Promise<void> {
197
+ if (state.loaded) {
198
+ return;
199
+ }
200
+
201
+ await mkdir(OUTBOX_DIR, { recursive: true });
202
+ await cleanupOutboxTmpFile();
203
+
204
+ try {
205
+ const raw = await readFile(OUTBOX_FILE, "utf-8");
206
+ const parsed = JSON.parse(raw) as OutboxEntry[];
207
+ state.entries = Array.isArray(parsed)
208
+ ? parsed.map((entry) => normalizeEntry(entry))
209
+ : [];
210
+ } catch (err) {
211
+ const code = err && typeof err === "object" && "code" in err ? String((err as { code?: string }).code) : "";
212
+ if (code !== "ENOENT") {
213
+ state.log.error("[mixin] failed to load outbox", err);
214
+ }
215
+ state.entries = [];
216
+ }
217
+
218
+ state.loaded = true;
219
+ await persistEntries();
220
+ await warnIfOutboxFileTooLarge();
221
+ }
222
+
223
+ function queuePersist(task: () => Promise<void>): Promise<void> {
224
+ const next = state.persistChain.then(task);
225
+ state.persistChain = next.catch(() => {});
226
+ return next;
227
+ }
228
+
229
+ async function persistEntries(): Promise<void> {
230
+ await queuePersist(async () => {
231
+ await mkdir(OUTBOX_DIR, { recursive: true });
232
+ 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);
235
+ await warnIfOutboxFileTooLarge();
236
+ });
237
+ }
238
+
239
+ function wakeWorker(): void {
240
+ state.wakeRequested = true;
241
+ if (state.wakeResolver) {
242
+ const resolve = state.wakeResolver;
243
+ state.wakeResolver = null;
244
+ resolve();
245
+ }
246
+ }
247
+
248
+ async function waitForWake(delayMs: number): Promise<void> {
249
+ if (state.wakeRequested) {
250
+ state.wakeRequested = false;
251
+ return;
252
+ }
253
+
254
+ await new Promise<void>((resolve) => {
255
+ const timeout = setTimeout(() => {
256
+ state.wakeResolver = null;
257
+ resolve();
258
+ }, delayMs);
259
+
260
+ state.wakeResolver = () => {
261
+ clearTimeout(timeout);
262
+ state.wakeResolver = null;
263
+ resolve();
264
+ };
265
+ });
266
+
267
+ state.wakeRequested = false;
268
+ }
269
+
270
+ function getNextWakeDelay(): number {
271
+ const next = state.entries.reduce<number | null>((min, entry) => {
272
+ if (entry.status !== "pending") {
273
+ return min;
274
+ }
275
+ if (min === null || entry.nextAttemptAt < min) {
276
+ return entry.nextAttemptAt;
277
+ }
278
+ return min;
279
+ }, null);
280
+
281
+ if (next === null) {
282
+ return 5000;
283
+ }
284
+
285
+ return Math.max(0, next - Date.now());
286
+ }
287
+
288
+ async function attemptSend(entry: OutboxEntry): Promise<void> {
289
+ if (!state.cfg) {
290
+ throw new Error("send worker config not initialized");
291
+ }
292
+
293
+ const config = getAccountConfig(state.cfg, entry.accountId);
294
+ if (!config.appId || !config.sessionId || !config.serverPublicKey || !config.sessionPrivateKey) {
295
+ throw new Error(`account ${entry.accountId} is not fully configured`);
296
+ }
297
+
298
+ const client = buildClient(config);
299
+ const messagePayload: {
300
+ conversation_id: string;
301
+ message_id: string;
302
+ category: MixinSupportedMessageCategory;
303
+ data_base64: string;
304
+ recipient_id?: string;
305
+ } = {
306
+ conversation_id: entry.conversationId,
307
+ message_id: entry.messageId,
308
+ category: entry.category,
309
+ data_base64: Buffer.from(entry.body).toString("base64"),
310
+ };
311
+
312
+ if (entry.recipientId) {
313
+ messagePayload.recipient_id = entry.recipientId;
314
+ }
315
+
316
+ await client.message.sendOne(messagePayload);
317
+ }
318
+
319
+ async function processEntry(entry: OutboxEntry): Promise<void> {
320
+ entry.status = "sending";
321
+ entry.updatedAt = new Date().toISOString();
322
+ await persistEntries();
323
+
324
+ try {
325
+ await attemptSend(entry);
326
+ state.entries = state.entries.filter((item) => item.jobId !== entry.jobId);
327
+ await persistEntries();
328
+ state.log.info(
329
+ `[mixin] outbox sent: jobId=${entry.jobId}, messageId=${entry.messageId}, attempts=${entry.attempts + 1}`,
330
+ );
331
+ } catch (err) {
332
+ const msg = err instanceof Error ? err.message : String(err);
333
+ entry.status = "pending";
334
+ entry.attempts += 1;
335
+ entry.lastError = normalizeErrorMessage(msg);
336
+ entry.nextAttemptAt = Date.now() + computeNextDelay(entry.attempts);
337
+ entry.updatedAt = new Date().toISOString();
338
+ await persistEntries();
339
+ state.log.warn(
340
+ `[mixin] outbox retry scheduled: jobId=${entry.jobId}, messageId=${entry.messageId}, attempts=${entry.attempts}, delayMs=${Math.max(0, entry.nextAttemptAt - Date.now())}, error=${msg}`,
341
+ );
342
+ }
343
+ }
344
+
345
+ async function processDueEntries(): Promise<void> {
346
+ const now = Date.now();
347
+ const dueEntries = state.entries
348
+ .filter((entry) => entry.status === "pending" && entry.nextAttemptAt <= now)
349
+ .sort((a, b) => {
350
+ if (a.nextAttemptAt !== b.nextAttemptAt) {
351
+ return a.nextAttemptAt - b.nextAttemptAt;
352
+ }
353
+ return a.createdAt.localeCompare(b.createdAt);
354
+ });
355
+
356
+ for (const entry of dueEntries) {
357
+ await processEntry(entry);
358
+ }
359
+ }
360
+
361
+ async function runWorkerLoop(): Promise<void> {
362
+ while (true) {
363
+ try {
364
+ await ensureOutboxLoaded();
365
+ await processDueEntries();
366
+ } catch (err) {
367
+ state.log.error("[mixin] outbox worker error", err);
368
+ await sleep(BASE_DELAY);
369
+ }
370
+
371
+ await waitForWake(getNextWakeDelay());
372
+ }
373
+ }
374
+
375
+ export async function startSendWorker(cfg: OpenClawConfig, log?: SendLog): Promise<void> {
376
+ updateRuntime(cfg, log);
377
+ await ensureOutboxLoaded();
378
+
379
+ if (!state.started) {
380
+ state.started = true;
381
+ void runWorkerLoop();
382
+ } else {
383
+ wakeWorker();
384
+ }
385
+ }
386
+
387
+ export async function getOutboxStatus(): Promise<OutboxStatus> {
388
+ await ensureOutboxLoaded();
389
+
390
+ const sorted = [...state.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
391
+ const oldest = sorted[0];
392
+ const nextAttempt = state.entries.reduce<number | null>((min, entry) => {
393
+ if (entry.status !== "pending") {
394
+ return min;
395
+ }
396
+ if (min === null || entry.nextAttemptAt < min) {
397
+ return entry.nextAttemptAt;
398
+ }
399
+ return min;
400
+ }, null);
401
+
402
+ const latestErrorEntry = [...state.entries]
403
+ .filter((entry) => entry.lastError)
404
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
405
+
406
+ const pendingByAccount = Array.from(
407
+ state.entries.reduce<Map<string, number>>((map, entry) => {
408
+ map.set(entry.accountId, (map.get(entry.accountId) ?? 0) + 1);
409
+ return map;
410
+ }, new Map<string, number>()),
411
+ )
412
+ .map(([accountId, pending]) => ({ accountId, pending }))
413
+ .sort((a, b) => a.accountId.localeCompare(b.accountId));
414
+
415
+ return {
416
+ totalPending: state.entries.length,
417
+ pendingByAccount,
418
+ oldestPendingAt: oldest?.createdAt,
419
+ nextAttemptAt: nextAttempt ? new Date(nextAttempt).toISOString() : undefined,
420
+ latestError: latestErrorEntry?.lastError,
421
+ };
422
+ }
423
+
424
+ export async function purgePermanentInvalidOutboxEntries(): Promise<OutboxPurgeResult> {
425
+ await ensureOutboxLoaded();
426
+
427
+ const removedEntries = state.entries.filter((entry) => isPermanentInvalidEntry(entry));
428
+ if (removedEntries.length === 0) {
429
+ return { removed: 0, removedJobIds: [] };
430
+ }
431
+
432
+ const removedJobIds = removedEntries.map((entry) => entry.jobId);
433
+ state.entries = state.entries.filter((entry) => !isPermanentInvalidEntry(entry));
434
+ await persistEntries();
435
+
436
+ return {
437
+ removed: removedEntries.length,
438
+ removedJobIds,
439
+ };
440
+ }
441
+
442
+ export async function sendTextMessage(
443
+ cfg: OpenClawConfig,
444
+ accountId: string,
445
+ conversationId: string,
446
+ recipientId: string | undefined,
447
+ text: string,
448
+ log?: SendLog,
449
+ ): Promise<SendResult> {
450
+ return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_TEXT", text, log);
451
+ }
452
+
453
+ export async function sendPostMessage(
454
+ cfg: OpenClawConfig,
455
+ accountId: string,
456
+ conversationId: string,
457
+ recipientId: string | undefined,
458
+ text: string,
459
+ log?: SendLog,
460
+ ): Promise<SendResult> {
461
+ return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_POST", text, log);
462
+ }
463
+
464
+ export async function sendButtonGroupMessage(
465
+ cfg: OpenClawConfig,
466
+ accountId: string,
467
+ conversationId: string,
468
+ recipientId: string | undefined,
469
+ buttons: MixinButton[],
470
+ log?: SendLog,
471
+ ): Promise<SendResult> {
472
+ const lines = buttons.map((button, index) => `${index + 1}. ${button.label}: ${button.action}`);
473
+ return sendPostMessage(cfg, accountId, conversationId, recipientId, lines.join("\n"), log);
474
+ }
475
+
476
+ export async function sendCardMessage(
477
+ cfg: OpenClawConfig,
478
+ accountId: string,
479
+ conversationId: string,
480
+ recipientId: string | undefined,
481
+ card: MixinCard,
482
+ log?: SendLog,
483
+ ): Promise<SendResult> {
484
+ const lines = [card.title, "", card.description];
485
+
486
+ if (card.action) {
487
+ lines.push("", `Open: ${card.action}`);
488
+ }
489
+
490
+ if (card.actions && card.actions.length > 0) {
491
+ lines.push("", ...card.actions.map((button, index) => `${index + 1}. ${button.label}: ${button.action}`));
492
+ }
493
+
494
+ return sendPostMessage(cfg, accountId, conversationId, recipientId, lines.join("\n"), log);
495
+ }
496
+
497
+ async function sendMixinMessage(
498
+ cfg: OpenClawConfig,
499
+ accountId: string,
500
+ conversationId: string,
501
+ recipientId: string | undefined,
502
+ category: MixinSupportedMessageCategory,
503
+ body: string,
504
+ log?: SendLog,
505
+ ): Promise<SendResult> {
506
+ updateRuntime(cfg, log);
507
+ await startSendWorker(cfg, log);
508
+
509
+ const now = new Date().toISOString();
510
+ const entry: OutboxEntry = {
511
+ jobId: crypto.randomUUID(),
512
+ accountId,
513
+ conversationId,
514
+ recipientId,
515
+ category,
516
+ body,
517
+ messageId: crypto.randomUUID(),
518
+ attempts: 0,
519
+ nextAttemptAt: Date.now(),
520
+ createdAt: now,
521
+ updatedAt: now,
522
+ status: "pending",
523
+ };
524
+
525
+ state.entries.push(entry);
526
+ await persistEntries();
527
+ wakeWorker();
528
+
529
+ state.log.info(
530
+ `[mixin] outbox enqueued: jobId=${entry.jobId}, messageId=${entry.messageId}, category=${category}, accountId=${accountId}, conversation=${conversationId}`,
531
+ );
532
+
533
+ return { ok: true, messageId: entry.messageId };
534
+ }
535
+
536
+ export async function acknowledgeMessage(
537
+ cfg: OpenClawConfig,
538
+ accountId: string,
539
+ messageId: string,
540
+ ): Promise<void> {
541
+ try {
542
+ const config = getAccountConfig(cfg, accountId);
543
+ if (!config.appId || !config.sessionId || !config.serverPublicKey || !config.sessionPrivateKey) {
544
+ return;
545
+ }
546
+ const client = buildClient(config);
547
+ await client.message.sendAcknowledgement(
548
+ { message_id: messageId, status: "READ" },
549
+ );
550
+ } catch (err) {
551
+ void err;
552
+ }
553
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "outDir": "./dist",
9
+ "rootDir": "./src",
10
+ "esModuleInterop": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*.ts"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }