@invago/mixin 1.0.9 → 1.0.10

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.
@@ -9,12 +9,8 @@ import WebSocket from "ws";
9
9
  import crypto from "crypto";
10
10
  import type { MixinAccountConfig } from "./config-schema.js";
11
11
  import { createProxyAgent } from "./proxy.js";
12
-
13
- type SendLog = {
14
- info: (msg: string) => void;
15
- error: (msg: string, err?: unknown) => void;
16
- warn: (msg: string) => void;
17
- };
12
+ import type { MixinBlazeOutboundMessage } from "./runtime.js";
13
+ import type { SendLog } from "./shared.js";
18
14
 
19
15
  function buildKeystore(config: MixinAccountConfig) {
20
16
  return {
@@ -50,8 +46,9 @@ export async function runBlazeLoop(params: {
50
46
  handler: BlazeHandler;
51
47
  log: SendLog;
52
48
  abortSignal?: AbortSignal;
49
+ onSenderReady?: ((sender: ((message: MixinBlazeOutboundMessage) => Promise<void>) | null) => void) | undefined;
53
50
  }): Promise<void> {
54
- const { config, options, handler, log, abortSignal } = params;
51
+ const { config, options, handler, log, abortSignal, onSenderReady } = params;
55
52
  const keystore = buildKeystore(config);
56
53
  const jwtToken = signAccessToken("GET", "/", "", crypto.randomUUID(), keystore) || "";
57
54
  const agent = createProxyAgent(config.proxy);
@@ -67,6 +64,7 @@ export async function runBlazeLoop(params: {
67
64
  clearTimeout(pingTimeout);
68
65
  pingTimeout = null;
69
66
  }
67
+ onSenderReady?.(null);
70
68
  abortSignal?.removeEventListener("abort", onAbort);
71
69
  };
72
70
 
@@ -118,6 +116,25 @@ export async function runBlazeLoop(params: {
118
116
  ws.on("open", () => {
119
117
  opened = true;
120
118
  heartbeat();
119
+ onSenderReady?.(async (message) => {
120
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
121
+ throw new Error("blaze sender unavailable: socket not open");
122
+ }
123
+ const ok = await sendRaw(ws, {
124
+ id: crypto.randomUUID(),
125
+ action: "CREATE_MESSAGE",
126
+ params: {
127
+ conversation_id: message.conversationId,
128
+ status: "SENT",
129
+ message_id: message.messageId,
130
+ category: message.category,
131
+ data: message.dataBase64,
132
+ },
133
+ });
134
+ if (!ok) {
135
+ throw new Error("blaze sender timeout");
136
+ }
137
+ });
121
138
  void sendRaw(ws!, {
122
139
  id: crypto.randomUUID(),
123
140
  action: "LIST_PENDING_MESSAGES",
package/src/channel.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { uniqueConversationID } from "@mixin.dev/mixin-node-sdk";
3
4
  import {
4
5
  buildChannelConfigSchema,
5
6
  createDefaultChannelRuntimeState,
@@ -8,11 +9,14 @@ import {
8
9
  } from "openclaw/plugin-sdk";
9
10
  import type { ChannelGatewayContext, OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
10
11
  import { runBlazeLoop } from "./blaze-service.js";
12
+ import { buildClient, sleep } from "./shared.js";
11
13
  import { MixinConfigSchema } from "./config-schema.js";
12
14
  import { describeAccount, isConfigured, listAccountIds, resolveAccount, resolveDefaultAccountId, resolveMediaMaxMb } from "./config.js";
15
+ import type { MixinAccountConfig } from "./config-schema.js";
13
16
  import { handleMixinMessage, type MixinInboundMessage } from "./inbound-handler.js";
17
+ import { getMixpayStatusSnapshot, startMixpayWorker } from "./mixpay-worker.js";
14
18
  import { buildMixinOutboundPlanFromReplyPayload, executeMixinOutboundPlan } from "./outbound-plan.js";
15
- import { getMixinRuntime } from "./runtime.js";
19
+ import { getMixinRuntime, setMixinBlazeSender } from "./runtime.js";
16
20
  import { getOutboxStatus, sendAudioMessage, sendFileMessage, sendTextMessage, startSendWorker } from "./send-service.js";
17
21
  import { buildMixinAccountSnapshot, buildMixinChannelSummary, resolveMixinStatusSnapshot } from "./status.js";
18
22
 
@@ -23,10 +27,12 @@ const MAX_DELAY = 3000;
23
27
  const MULTIPLIER = 1.5;
24
28
  const MEDIA_MAX_BYTES = 30 * 1024 * 1024;
25
29
  const execFileAsync = promisify(execFile);
30
+ const CONVERSATION_CATEGORY_CACHE_TTL_MS = 5 * 60 * 1000;
26
31
 
27
- async function sleep(ms: number): Promise<void> {
28
- return new Promise((resolve) => setTimeout(resolve, ms));
29
- }
32
+ const conversationCategoryCache = new Map<string, {
33
+ category: "CONTACT" | "GROUP";
34
+ expiresAt: number;
35
+ }>();
30
36
 
31
37
  function maskKey(key: string): string {
32
38
  if (!key || key.length < 8) {
@@ -35,6 +41,65 @@ function maskKey(key: string): string {
35
41
  return key.slice(0, 4) + "****" + key.slice(-4);
36
42
  }
37
43
 
44
+ async function resolveIsDirectMessage(params: {
45
+ config: MixinAccountConfig;
46
+ conversationId?: string;
47
+ userId?: string;
48
+ log: {
49
+ info: (m: string) => void;
50
+ warn: (m: string) => void;
51
+ };
52
+ }): Promise<boolean> {
53
+ const conversationId = params.conversationId?.trim();
54
+ if (!conversationId) {
55
+ return true;
56
+ }
57
+
58
+ const cached = conversationCategoryCache.get(conversationId);
59
+ if (cached && cached.expiresAt > Date.now()) {
60
+ params.log.info(`[mixin] conversation category resolved from cache: conversationId=${conversationId}, category=${cached.category}`);
61
+ return cached.category !== "GROUP";
62
+ }
63
+
64
+ const now = Date.now();
65
+ for (const [key, entry] of conversationCategoryCache) {
66
+ if (entry.expiresAt <= now) {
67
+ conversationCategoryCache.delete(key);
68
+ }
69
+ }
70
+
71
+ try {
72
+ const client = buildClient(params.config);
73
+ const conversation = await client.conversation.fetch(conversationId);
74
+ const category = conversation.category === "GROUP" ? "GROUP" : "CONTACT";
75
+ conversationCategoryCache.set(conversationId, {
76
+ category,
77
+ expiresAt: Date.now() + CONVERSATION_CATEGORY_CACHE_TTL_MS,
78
+ });
79
+ params.log.info(`[mixin] conversation category resolved: conversationId=${conversationId}, category=${category}`);
80
+ return category !== "GROUP";
81
+ } catch (err) {
82
+ const userId = params.userId?.trim();
83
+ if (userId && params.config.appId) {
84
+ const directConversationId = uniqueConversationID(params.config.appId, userId);
85
+ if (directConversationId === conversationId) {
86
+ params.log.info(
87
+ `[mixin] conversation category inferred locally: conversationId=${conversationId}, category=CONTACT`,
88
+ );
89
+ conversationCategoryCache.set(conversationId, {
90
+ category: "CONTACT",
91
+ expiresAt: Date.now() + CONVERSATION_CATEGORY_CACHE_TTL_MS,
92
+ });
93
+ return true;
94
+ }
95
+ }
96
+ params.log.warn(
97
+ `[mixin] failed to resolve conversation category: conversationId=${conversationId}, error=${err instanceof Error ? err.message : String(err)}`,
98
+ );
99
+ return false;
100
+ }
101
+ }
102
+
38
103
  async function resolveAudioDurationSeconds(filePath: string): Promise<number | null> {
39
104
  try {
40
105
  const { stdout } = await execFileAsync(
@@ -279,7 +344,9 @@ export const mixinPlugin = {
279
344
 
280
345
  await startSendWorker(cfg, log);
281
346
  const outboxStatus = await getOutboxStatus().catch(() => null);
282
- const statusSnapshot = resolveMixinStatusSnapshot(cfg, accountId, outboxStatus);
347
+ await startMixpayWorker(cfg, log);
348
+ const mixpayStatus = await getMixpayStatusSnapshot().catch(() => null);
349
+ const statusSnapshot = resolveMixinStatusSnapshot(cfg, accountId, outboxStatus, mixpayStatus);
283
350
  ctx.setStatus({
284
351
  accountId,
285
352
  ...statusSnapshot,
@@ -288,6 +355,7 @@ export const mixinPlugin = {
288
355
  let stopped = false;
289
356
  const stop = () => {
290
357
  stopped = true;
358
+ setMixinBlazeSender(accountId, null);
291
359
  };
292
360
  abortSignal?.addEventListener("abort", stop);
293
361
 
@@ -305,6 +373,9 @@ export const mixinPlugin = {
305
373
  options: { parse: false, syncAck: true },
306
374
  log,
307
375
  abortSignal,
376
+ onSenderReady: (sender) => {
377
+ setMixinBlazeSender(accountId, sender);
378
+ },
308
379
  handler: {
309
380
  onMessage: async (rawMsg: any) => {
310
381
  if (stopped) {
@@ -317,9 +388,15 @@ export const mixinPlugin = {
317
388
  return;
318
389
  }
319
390
 
320
- const isDirect = rawMsg.conversation_id === undefined
321
- ? true
322
- : !rawMsg.representative_id;
391
+ const isDirect = await resolveIsDirectMessage({
392
+ config,
393
+ conversationId: rawMsg.conversation_id,
394
+ userId: rawMsg.user_id,
395
+ log,
396
+ });
397
+ log.info(
398
+ `[mixin] inbound route context: messageId=${rawMsg.message_id}, conversationId=${rawMsg.conversation_id ?? ""}, userId=${rawMsg.user_id}, isDirect=${isDirect}`,
399
+ );
323
400
 
324
401
  const msg: MixinInboundMessage = {
325
402
  conversationId: rawMsg.conversation_id ?? "",
@@ -32,6 +32,21 @@ export const MixinConversationConfigSchema = z.object({
32
32
 
33
33
  export type MixinConversationConfig = z.infer<typeof MixinConversationConfigSchema>;
34
34
 
35
+ export const MixinMixpayConfigSchema = z.object({
36
+ enabled: z.boolean().optional().default(false),
37
+ apiBaseUrl: z.string().optional(),
38
+ payeeId: z.string().optional(),
39
+ defaultQuoteAssetId: z.string().optional(),
40
+ defaultSettlementAssetId: z.string().optional(),
41
+ expireMinutes: z.number().positive().optional().default(15),
42
+ pollIntervalSec: z.number().positive().optional().default(30),
43
+ allowedCreators: z.array(z.string()).optional().default([]),
44
+ notifyOnPending: z.boolean().optional().default(false),
45
+ notifyOnPaidLess: z.boolean().optional().default(true),
46
+ });
47
+
48
+ export type MixinMixpayConfig = z.infer<typeof MixinMixpayConfigSchema>;
49
+
35
50
  export const MixinAccountConfigSchema = z.object({
36
51
  name: z.string().optional(),
37
52
  enabled: z.boolean().optional().default(true),
@@ -49,6 +64,7 @@ export const MixinAccountConfigSchema = z.object({
49
64
  audioAutoDetectDuration: z.boolean().optional().default(true),
50
65
  audioSendAsVoiceByDefault: z.boolean().optional().default(true),
51
66
  audioRequireFfprobe: z.boolean().optional().default(false),
67
+ mixpay: MixinMixpayConfigSchema.optional(),
52
68
  conversations: z.record(z.string(), MixinConversationConfigSchema.optional()).optional(),
53
69
  debug: z.boolean().optional().default(false),
54
70
  proxy: MixinProxyConfigSchema.optional(),
package/src/config.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  MixinConversationConfigSchema,
5
5
  type MixinAccountConfig,
6
6
  type MixinConversationConfig,
7
+ type MixinMixpayConfig,
7
8
  } from "./config-schema.js";
8
9
 
9
10
  type RawMixinConfig = Partial<MixinAccountConfig> & {
@@ -87,6 +88,10 @@ export function resolveMediaMaxMb(cfg: OpenClawConfig, accountId?: string): numb
87
88
  return getAccountConfig(cfg, accountId).mediaMaxMb;
88
89
  }
89
90
 
91
+ export function getMixpayConfig(cfg: OpenClawConfig, accountId?: string): MixinMixpayConfig | undefined {
92
+ return getAccountConfig(cfg, accountId).mixpay;
93
+ }
94
+
90
95
  function getRawAccountConfig(cfg: OpenClawConfig, accountId?: string): Partial<MixinAccountConfig> {
91
96
  const raw = getRawConfig(cfg);
92
97
  const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg);
package/src/crypto.ts CHANGED
@@ -12,6 +12,11 @@ function aes256CbcDecrypt(key: Buffer, iv: Buffer, ciphertext: Buffer): Buffer {
12
12
  const final = Buffer.concat([decrypted, decipher.final()]);
13
13
  const padLen = final[final.length - 1];
14
14
  if (padLen > 0 && padLen <= 16) {
15
+ for (let i = final.length - padLen; i < final.length; i++) {
16
+ if (final[i] !== padLen) {
17
+ return final;
18
+ }
19
+ }
15
20
  return final.slice(0, final.length - padLen);
16
21
  }
17
22
  return final;