@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.
@@ -0,0 +1,353 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { getMixpayConfig } from "./config.js";
3
+ import type { MixinMixpayConfig } from "./config-schema.js";
4
+ import { createMixpayPayment, getMixpayPaymentResult, type MixpayPaymentResult } from "./mixpay-service.js";
5
+ import {
6
+ createMixpayOrder,
7
+ findMixpayOrder,
8
+ getMixpayStoreSnapshot,
9
+ listPendingMixpayOrders,
10
+ listRecentMixpayOrders,
11
+ type MixpayOrderRecord,
12
+ type MixpayOrderStatus,
13
+ updateMixpayOrder,
14
+ } from "./mixpay-store.js";
15
+ import { sendTextMessage } from "./send-service.js";
16
+ import { sleep, type SendLog } from "./shared.js";
17
+
18
+ export type MixinCollectRequest = {
19
+ amount: string;
20
+ assetId?: string;
21
+ settlementAssetId?: string;
22
+ memo?: string;
23
+ orderId?: string;
24
+ expireMinutes?: number;
25
+ };
26
+
27
+ const state: {
28
+ started: boolean;
29
+ cfg: OpenClawConfig | null;
30
+ log: SendLog | null;
31
+ } = {
32
+ started: false,
33
+ cfg: null,
34
+ log: null,
35
+ };
36
+
37
+ function getPendingPollDelayMs(cfg: OpenClawConfig): number {
38
+ const configured = Object.values((cfg.channels?.mixin as { accounts?: Record<string, { mixpay?: MixinMixpayConfig }> } | undefined)?.accounts ?? {})
39
+ .map((account) => account?.mixpay?.pollIntervalSec)
40
+ .filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0);
41
+ const topLevel = (cfg.channels?.mixin as { mixpay?: MixinMixpayConfig } | undefined)?.mixpay?.pollIntervalSec;
42
+ if (typeof topLevel === "number" && Number.isFinite(topLevel) && topLevel > 0) {
43
+ configured.push(topLevel);
44
+ }
45
+ return Math.max(5_000, Math.min(...(configured.length > 0 ? configured : [30])) * 1000);
46
+ }
47
+
48
+ function formatStatusLabel(status: MixpayOrderStatus): string {
49
+ switch (status) {
50
+ case "unpaid":
51
+ return "unpaid";
52
+ case "pending":
53
+ return "pending";
54
+ case "paid_less":
55
+ return "paid less";
56
+ case "success":
57
+ return "success";
58
+ case "failed":
59
+ return "failed";
60
+ case "expired":
61
+ return "expired";
62
+ default:
63
+ return "unknown";
64
+ }
65
+ }
66
+
67
+ export function formatMixpayOrderSummary(order: MixpayOrderRecord): string {
68
+ const lines = [
69
+ `MixPay order: ${order.orderId}`,
70
+ `Status: ${formatStatusLabel(order.status)}`,
71
+ `Amount: ${order.quoteAmount} ${order.quoteAssetId}`,
72
+ ];
73
+ if (order.paymentUrl) {
74
+ lines.push(`Pay: ${order.paymentUrl}`);
75
+ }
76
+ if (order.memo) {
77
+ lines.push(`Memo: ${order.memo}`);
78
+ }
79
+ if (order.expireAt) {
80
+ lines.push(`Expires: ${order.expireAt}`);
81
+ }
82
+ if (order.latestError) {
83
+ lines.push(`Latest error: ${order.latestError}`);
84
+ }
85
+ return lines.join("\n");
86
+ }
87
+
88
+ function shouldNotifyStatus(config: MixinMixpayConfig, status: MixpayOrderStatus): boolean {
89
+ if (status === "success" || status === "failed" || status === "expired") {
90
+ return true;
91
+ }
92
+ if (status === "pending") {
93
+ return config.notifyOnPending === true;
94
+ }
95
+ if (status === "paid_less") {
96
+ return config.notifyOnPaidLess !== false;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ function validateMixpayResult(
102
+ order: MixpayOrderRecord,
103
+ payment: MixpayPaymentResult,
104
+ config: MixinMixpayConfig,
105
+ ): string | null {
106
+ if (config.payeeId?.trim() && payment.payeeId?.trim() && config.payeeId.trim() !== payment.payeeId.trim()) {
107
+ return "MixPay payeeId mismatch";
108
+ }
109
+ if (payment.quoteAssetId?.trim() && payment.quoteAssetId.trim() !== order.quoteAssetId.trim()) {
110
+ return "MixPay quoteAssetId mismatch";
111
+ }
112
+ if (payment.quoteAmount?.trim() && payment.quoteAmount.trim() !== order.quoteAmount.trim()) {
113
+ return "MixPay quoteAmount mismatch";
114
+ }
115
+ return null;
116
+ }
117
+
118
+ async function notifyOrderStatus(
119
+ cfg: OpenClawConfig,
120
+ order: MixpayOrderRecord,
121
+ nextStatus: MixpayOrderStatus,
122
+ log: SendLog,
123
+ ): Promise<void> {
124
+ const recipientId = order.recipientId || undefined;
125
+ const message = [
126
+ `MixPay order update: ${order.orderId}`,
127
+ `Status: ${formatStatusLabel(nextStatus)}`,
128
+ `Amount: ${order.quoteAmount} ${order.quoteAssetId}`,
129
+ ];
130
+ if (order.paymentUrl) {
131
+ message.push(`Pay: ${order.paymentUrl}`);
132
+ }
133
+ const result = await sendTextMessage(cfg, order.accountId, order.conversationId, recipientId, message.join("\n"), log);
134
+ if (!result.ok) {
135
+ throw new Error(result.error ?? "failed to notify MixPay order status");
136
+ }
137
+ }
138
+
139
+ async function pollPendingOrders(): Promise<void> {
140
+ const cfg = state.cfg;
141
+ const log = state.log;
142
+ if (!cfg || !log) {
143
+ return;
144
+ }
145
+
146
+ const pendingOrders = await listPendingMixpayOrders();
147
+ for (const order of pendingOrders) {
148
+ const mixpayConfig = getMixpayConfig(cfg, order.accountId);
149
+ if (!mixpayConfig?.enabled) {
150
+ continue;
151
+ }
152
+
153
+ try {
154
+ const payment = await getMixpayPaymentResult({ config: mixpayConfig, orderId: order.orderId, traceId: order.traceId });
155
+ const mismatch = validateMixpayResult(order, payment, mixpayConfig);
156
+ if (mismatch) {
157
+ throw new Error(mismatch);
158
+ }
159
+ const nextStatus = payment.status;
160
+ const updated = await updateMixpayOrder(order.orderId, (current) => ({
161
+ ...current,
162
+ paymentId: current.paymentId ?? payment.raw.id as string | undefined,
163
+ rawStatus: payment.rawStatus,
164
+ status: nextStatus,
165
+ lastPolledAt: new Date().toISOString(),
166
+ updatedAt: new Date().toISOString(),
167
+ }));
168
+
169
+ if (!updated) {
170
+ continue;
171
+ }
172
+
173
+ if (updated.lastNotifyStatus !== nextStatus && shouldNotifyStatus(mixpayConfig, nextStatus)) {
174
+ await notifyOrderStatus(cfg, updated, nextStatus, log);
175
+ await updateMixpayOrder(order.orderId, (current) => ({
176
+ ...current,
177
+ lastNotifyStatus: nextStatus,
178
+ updatedAt: new Date().toISOString(),
179
+ }));
180
+ }
181
+ } catch (err) {
182
+ log.warn(`[mixin] MixPay poll failed: orderId=${order.orderId}, error=${err instanceof Error ? err.message : String(err)}`);
183
+ await updateMixpayOrder(order.orderId, (current) => ({
184
+ ...current,
185
+ latestError: err instanceof Error ? err.message : String(err),
186
+ lastPolledAt: new Date().toISOString(),
187
+ updatedAt: new Date().toISOString(),
188
+ }));
189
+ }
190
+ }
191
+ }
192
+
193
+ export async function startMixpayWorker(cfg: OpenClawConfig, log: SendLog): Promise<void> {
194
+ state.cfg = cfg;
195
+ state.log = log;
196
+ if (state.started) {
197
+ return;
198
+ }
199
+ state.started = true;
200
+
201
+ void (async () => {
202
+ while (true) {
203
+ try {
204
+ await pollPendingOrders();
205
+ } catch (err) {
206
+ log.error("[mixin] MixPay worker loop failed", err);
207
+ }
208
+ await sleep(getPendingPollDelayMs(state.cfg ?? cfg));
209
+ }
210
+ })();
211
+ }
212
+
213
+ export async function createMixinCollectOrder(params: {
214
+ cfg: OpenClawConfig;
215
+ accountId: string;
216
+ conversationId: string;
217
+ recipientId?: string;
218
+ creatorId: string;
219
+ request: MixinCollectRequest;
220
+ }): Promise<MixpayOrderRecord> {
221
+ const mixpayConfig = getMixpayConfig(params.cfg, params.accountId);
222
+ if (!mixpayConfig?.enabled) {
223
+ throw new Error("MixPay is not enabled for this Mixin account");
224
+ }
225
+
226
+ const normalizedCreatorId = params.creatorId.trim().toLowerCase();
227
+ const allowedCreators = (mixpayConfig.allowedCreators ?? []).map((item) => item.trim().toLowerCase()).filter(Boolean);
228
+ if (allowedCreators.length > 0 && !allowedCreators.includes(normalizedCreatorId)) {
229
+ throw new Error("MixPay collect creation is not allowed for this sender");
230
+ }
231
+
232
+ const created = await createMixpayPayment({
233
+ config: mixpayConfig,
234
+ orderId: params.request.orderId,
235
+ quoteAmount: params.request.amount,
236
+ quoteAssetId: params.request.assetId?.trim() || mixpayConfig.defaultQuoteAssetId?.trim() || "",
237
+ settlementAssetId: params.request.settlementAssetId,
238
+ memo: params.request.memo,
239
+ expireMinutes: params.request.expireMinutes,
240
+ });
241
+
242
+ const quoteAssetId = params.request.assetId?.trim() || mixpayConfig.defaultQuoteAssetId?.trim();
243
+ if (!quoteAssetId) {
244
+ throw new Error("MixPay quote asset is not configured");
245
+ }
246
+
247
+ const record: MixpayOrderRecord = {
248
+ orderId: created.orderId,
249
+ traceId: created.traceId,
250
+ paymentId: created.paymentId,
251
+ code: created.code,
252
+ paymentUrl: created.paymentUrl,
253
+ accountId: params.accountId,
254
+ conversationId: params.conversationId,
255
+ recipientId: params.recipientId,
256
+ creatorId: params.creatorId,
257
+ quoteAssetId,
258
+ quoteAmount: params.request.amount,
259
+ settlementAssetId: params.request.settlementAssetId,
260
+ memo: params.request.memo,
261
+ status: "unpaid",
262
+ createdAt: created.createdAt,
263
+ updatedAt: created.createdAt,
264
+ expireAt: created.expireAt,
265
+ };
266
+
267
+ await createMixpayOrder(record);
268
+ return record;
269
+ }
270
+
271
+ export async function getMixpayOrderStatusText(orderId: string): Promise<string> {
272
+ const order = await findMixpayOrder(orderId);
273
+ if (!order) {
274
+ return `MixPay order not found: ${orderId}`;
275
+ }
276
+ return formatMixpayOrderSummary(order);
277
+ }
278
+
279
+ export async function refreshMixpayOrderStatus(params: {
280
+ cfg: OpenClawConfig;
281
+ accountId: string;
282
+ orderId: string;
283
+ }): Promise<MixpayOrderRecord | null> {
284
+ const order = await findMixpayOrder(params.orderId);
285
+ if (!order || order.accountId !== params.accountId) {
286
+ return order;
287
+ }
288
+
289
+ const mixpayConfig = getMixpayConfig(params.cfg, params.accountId);
290
+ if (!mixpayConfig?.enabled) {
291
+ return order;
292
+ }
293
+
294
+ try {
295
+ const payment = await getMixpayPaymentResult({
296
+ config: mixpayConfig,
297
+ orderId: order.orderId,
298
+ traceId: order.traceId,
299
+ });
300
+ const mismatch = validateMixpayResult(order, payment, mixpayConfig);
301
+ if (mismatch) {
302
+ throw new Error(mismatch);
303
+ }
304
+ return await updateMixpayOrder(order.orderId, (current) => ({
305
+ ...current,
306
+ rawStatus: payment.rawStatus,
307
+ status: payment.status,
308
+ lastPolledAt: new Date().toISOString(),
309
+ updatedAt: new Date().toISOString(),
310
+ latestError: undefined,
311
+ }));
312
+ } catch (err) {
313
+ return await updateMixpayOrder(order.orderId, (current) => ({
314
+ ...current,
315
+ latestError: err instanceof Error ? err.message : String(err),
316
+ lastPolledAt: new Date().toISOString(),
317
+ updatedAt: new Date().toISOString(),
318
+ }));
319
+ }
320
+ }
321
+
322
+ export async function getRecentMixpayOrdersText(params: {
323
+ accountId: string;
324
+ conversationId: string;
325
+ limit?: number;
326
+ }): Promise<string> {
327
+ const items = await listRecentMixpayOrders({
328
+ accountId: params.accountId,
329
+ conversationId: params.conversationId,
330
+ limit: params.limit ?? 5,
331
+ });
332
+
333
+ if (items.length === 0) {
334
+ return "No MixPay orders found for this conversation.";
335
+ }
336
+
337
+ return items
338
+ .map((item) => `${item.orderId} | ${formatStatusLabel(item.status)} | ${item.quoteAmount} ${item.quoteAssetId}`)
339
+ .join("\n");
340
+ }
341
+
342
+ export async function getMixpayStatusSnapshot(): Promise<{
343
+ pendingOrders: number;
344
+ storeDir: string;
345
+ storeFile: string;
346
+ }> {
347
+ const snapshot = await getMixpayStoreSnapshot();
348
+ return {
349
+ pendingOrders: snapshot.pending,
350
+ storeDir: snapshot.storeDir,
351
+ storeFile: snapshot.storeFile,
352
+ };
353
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ReplyPayload } from "openclaw/plugin-sdk";
2
2
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import { createMixinCollectOrder, formatMixpayOrderSummary } from "./mixpay-worker.js";
3
4
  import { buildMixinReplyPlan, resolveMixinReplyPlan } from "./reply-format.js";
4
5
  import {
5
6
  sendAudioMessage,
@@ -9,18 +10,14 @@ import {
9
10
  sendPostMessage,
10
11
  sendTextMessage,
11
12
  } from "./send-service.js";
12
-
13
- type SendLog = {
14
- info: (msg: string) => void;
15
- warn: (msg: string) => void;
16
- error: (msg: string, err?: unknown) => void;
17
- };
13
+ import type { SendLog } from "./shared.js";
18
14
 
19
15
  export type MixinOutboundStep =
20
16
  | { kind: "text"; text: string }
21
17
  | { kind: "post"; text: string }
22
18
  | { kind: "file"; file: Parameters<typeof sendFileMessage>[4] }
23
19
  | { kind: "audio"; audio: Parameters<typeof sendAudioMessage>[4] }
20
+ | { kind: "collect"; collect: Parameters<typeof createMixinCollectOrder>[0]["request"] }
24
21
  | { kind: "buttons"; intro?: string; buttons: Parameters<typeof sendButtonGroupMessage>[4] }
25
22
  | { kind: "card"; card: Parameters<typeof sendCardMessage>[4] }
26
23
  | { kind: "media-url"; mediaUrl: string };
@@ -77,6 +74,10 @@ function appendReplyTextPlan(
77
74
  steps.push({ kind: "audio", audio: plan.audio });
78
75
  return;
79
76
  }
77
+ if (plan.kind === "collect") {
78
+ steps.push({ kind: "collect", collect: plan.collect });
79
+ return;
80
+ }
80
81
  if (plan.kind === "buttons") {
81
82
  steps.push({ kind: "buttons", intro: plan.intro, buttons: plan.buttons });
82
83
  return;
@@ -118,11 +119,12 @@ export async function executeMixinOutboundPlan(params: {
118
119
  accountId: string;
119
120
  conversationId: string;
120
121
  recipientId?: string;
122
+ creatorId?: string;
121
123
  steps: MixinOutboundStep[];
122
124
  log?: SendLog;
123
125
  sendMediaUrl?: (mediaUrl: string) => Promise<string | undefined>;
124
126
  }): Promise<string | undefined> {
125
- const { cfg, accountId, conversationId, recipientId, steps, log, sendMediaUrl } = params;
127
+ const { cfg, accountId, conversationId, recipientId, creatorId, steps, log, sendMediaUrl } = params;
126
128
  let lastMessageId: string | undefined;
127
129
 
128
130
  for (const step of steps) {
@@ -162,6 +164,23 @@ export async function executeMixinOutboundPlan(params: {
162
164
  continue;
163
165
  }
164
166
 
167
+ if (step.kind === "collect") {
168
+ const order = await createMixinCollectOrder({
169
+ cfg,
170
+ accountId,
171
+ conversationId,
172
+ recipientId,
173
+ creatorId: creatorId ?? recipientId ?? conversationId,
174
+ request: step.collect,
175
+ });
176
+ const result = await sendTextMessage(cfg, accountId, conversationId, recipientId, formatMixpayOrderSummary(order), log);
177
+ if (!result.ok) {
178
+ throw new Error(result.error ?? "mixin outbound MixPay collect send failed");
179
+ }
180
+ lastMessageId = result.messageId ?? lastMessageId;
181
+ continue;
182
+ }
183
+
165
184
  if (step.kind === "buttons") {
166
185
  if (step.intro) {
167
186
  const introResult = await sendTextMessage(cfg, accountId, conversationId, recipientId, step.intro, log);
@@ -1,3 +1,4 @@
1
+ import type { MixinCollectRequest } from "./mixpay-worker.js";
1
2
  import type { MixinAudio, MixinButton, MixinCard, MixinFile } from "./send-service.js";
2
3
 
3
4
  type LinkItem = {
@@ -10,6 +11,7 @@ export type MixinReplyPlan =
10
11
  | { kind: "post"; text: string }
11
12
  | { kind: "file"; file: MixinFile }
12
13
  | { kind: "audio"; audio: MixinAudio }
14
+ | { kind: "collect"; collect: MixinCollectRequest }
13
15
  | { kind: "buttons"; intro?: string; buttons: MixinButton[] }
14
16
  | { kind: "card"; card: MixinCard };
15
17
 
@@ -21,7 +23,7 @@ const MAX_BUTTONS = 6;
21
23
  const MAX_BUTTON_LABEL = 36;
22
24
  const MAX_CARD_TITLE = 36;
23
25
  const MAX_CARD_DESCRIPTION = 120;
24
- const TEMPLATE_REGEX = /^```mixin-(text|post|buttons|card|file|audio)\s*\n([\s\S]*?)\n```$/i;
26
+ const TEMPLATE_REGEX = /^```mixin-(text|post|buttons|card|file|audio|collect)\s*\n([\s\S]*?)\n```$/i;
25
27
 
26
28
  function truncate(value: string, limit: number): string {
27
29
  return value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 3))}...`;
@@ -170,6 +172,51 @@ function parseAudioTemplate(body: string): MixinReplyPlan | null {
170
172
  };
171
173
  }
172
174
 
175
+ function parseCollectTemplate(body: string): MixinReplyPlan | null {
176
+ const parsed = parseJsonTemplate<{
177
+ amount?: unknown;
178
+ assetId?: unknown;
179
+ quoteAssetId?: unknown;
180
+ settlementAssetId?: unknown;
181
+ memo?: unknown;
182
+ orderId?: unknown;
183
+ expireMinutes?: unknown;
184
+ }>(body);
185
+ if (!parsed) {
186
+ return null;
187
+ }
188
+
189
+ const amount = typeof parsed.amount === "string"
190
+ ? normalizeWhitespace(parsed.amount)
191
+ : typeof parsed.amount === "number"
192
+ ? String(parsed.amount)
193
+ : "";
194
+ const assetId = typeof parsed.assetId === "string"
195
+ ? normalizeWhitespace(parsed.assetId)
196
+ : typeof parsed.quoteAssetId === "string"
197
+ ? normalizeWhitespace(parsed.quoteAssetId)
198
+ : "";
199
+ if (!amount) {
200
+ return null;
201
+ }
202
+
203
+ return {
204
+ kind: "collect",
205
+ collect: {
206
+ amount,
207
+ assetId: assetId || undefined,
208
+ settlementAssetId: typeof parsed.settlementAssetId === "string"
209
+ ? normalizeWhitespace(parsed.settlementAssetId)
210
+ : undefined,
211
+ memo: typeof parsed.memo === "string" ? normalizeWhitespace(parsed.memo) : undefined,
212
+ orderId: typeof parsed.orderId === "string" ? normalizeWhitespace(parsed.orderId) : undefined,
213
+ expireMinutes: typeof parsed.expireMinutes === "number" && Number.isFinite(parsed.expireMinutes)
214
+ ? parsed.expireMinutes
215
+ : undefined,
216
+ },
217
+ };
218
+ }
219
+
173
220
  function parseExplicitTemplate(text: string): MixinReplyPlanResolution {
174
221
  const match = text.match(TEMPLATE_REGEX);
175
222
  if (!match) {
@@ -203,6 +250,10 @@ function parseExplicitTemplate(text: string): MixinReplyPlanResolution {
203
250
  return { matchedTemplate: true, plan: parseAudioTemplate(body), error: "Invalid mixin-audio template JSON" };
204
251
  }
205
252
 
253
+ if (templateType === "collect") {
254
+ return { matchedTemplate: true, plan: parseCollectTemplate(body), error: "Invalid mixin-collect template JSON" };
255
+ }
256
+
206
257
  return { matchedTemplate: true, plan: null, error: "Unknown Mixin template type" };
207
258
  }
208
259
 
package/src/runtime.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+ import type { MixinSupportedMessageCategory } from "./send-service.js";
2
3
 
3
4
  let runtime: PluginRuntime | null = null;
5
+ const blazeSenders = new Map<string, MixinBlazeSender>();
6
+
7
+ export type MixinBlazeOutboundMessage = {
8
+ conversationId: string;
9
+ messageId: string;
10
+ category: MixinSupportedMessageCategory;
11
+ dataBase64: string;
12
+ };
13
+
14
+ export type MixinBlazeSender = (message: MixinBlazeOutboundMessage) => Promise<void>;
4
15
 
5
16
  export function setMixinRuntime(next: PluginRuntime): void {
6
17
  runtime = next;
@@ -10,3 +21,18 @@ export function getMixinRuntime(): PluginRuntime {
10
21
  if (!runtime) throw new Error("Mixin runtime not initialized");
11
22
  return runtime;
12
23
  }
24
+
25
+ export function setMixinBlazeSender(accountId: string, sender: MixinBlazeSender | null): void {
26
+ if (!accountId.trim()) {
27
+ return;
28
+ }
29
+ if (sender) {
30
+ blazeSenders.set(accountId, sender);
31
+ return;
32
+ }
33
+ blazeSenders.delete(accountId);
34
+ }
35
+
36
+ export function getMixinBlazeSender(accountId: string): MixinBlazeSender | null {
37
+ return blazeSenders.get(accountId) ?? null;
38
+ }
@@ -2,12 +2,10 @@ import crypto from "crypto";
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
3
3
  import os from "os";
4
4
  import path from "path";
5
- import { MixinApi } from "@mixin.dev/mixin-node-sdk";
6
5
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
7
- import type { MixinAccountConfig } from "./config-schema.js";
8
6
  import { getAccountConfig } from "./config.js";
9
- import { buildRequestConfig } from "./proxy.js";
10
- import { getMixinRuntime } from "./runtime.js";
7
+ import { getMixinBlazeSender, getMixinRuntime } from "./runtime.js";
8
+ import { buildClient, sleep, type SendLog } from "./shared.js";
11
9
 
12
10
  const BASE_DELAY = 1000;
13
11
  const MAX_DELAY = 60_000;
@@ -15,12 +13,6 @@ const MULTIPLIER = 1.5;
15
13
  const MAX_ERROR_LENGTH = 500;
16
14
  const MAX_OUTBOX_FILE_BYTES = 10 * 1024 * 1024;
17
15
 
18
- type SendLog = {
19
- info: (msg: string) => void;
20
- error: (msg: string, err?: unknown) => void;
21
- warn: (msg: string) => void;
22
- };
23
-
24
16
  export type MixinSupportedMessageCategory =
25
17
  | "PLAIN_TEXT"
26
18
  | "PLAIN_POST"
@@ -139,22 +131,6 @@ const state: {
139
131
  wakeResolver: null,
140
132
  };
141
133
 
142
- function sleep(ms: number): Promise<void> {
143
- return new Promise((resolve) => setTimeout(resolve, ms));
144
- }
145
-
146
- function buildClient(config: MixinAccountConfig) {
147
- return MixinApi({
148
- keystore: {
149
- app_id: config.appId!,
150
- session_id: config.sessionId!,
151
- server_public_key: config.serverPublicKey!,
152
- session_private_key: config.sessionPrivateKey!,
153
- },
154
- requestConfig: buildRequestConfig(config.proxy),
155
- });
156
- }
157
-
158
134
  function guessMimeType(fileName: string): string {
159
135
  const ext = path.extname(fileName).toLowerCase();
160
136
  switch (ext) {
@@ -277,7 +253,7 @@ function normalizeEntry(entry: OutboxEntry): OutboxEntry {
277
253
  };
278
254
  }
279
255
 
280
- function isStructuredBody(body: string): body is string {
256
+ function isStructuredBody(body: string): boolean {
281
257
  return body.trim().startsWith("{");
282
258
  }
283
259
 
@@ -475,6 +451,23 @@ async function attemptSend(entry: OutboxEntry): Promise<void> {
475
451
  }
476
452
 
477
453
  const dataBase64 = Buffer.from(payloadBody).toString("base64");
454
+ if (!entry.recipientId) {
455
+ const blazeSender = getMixinBlazeSender(entry.accountId);
456
+ if (!blazeSender) {
457
+ throw new Error("group send failed: blaze sender unavailable");
458
+ }
459
+ state.log.info(
460
+ `[mixin] attempt send: transport=blaze, jobId=${entry.jobId}, messageId=${entry.messageId}, conversation=${entry.conversationId}, recipient=none, category=${entry.category}`,
461
+ );
462
+ await blazeSender({
463
+ conversationId: entry.conversationId,
464
+ messageId: entry.messageId,
465
+ category: entry.category,
466
+ dataBase64,
467
+ });
468
+ return;
469
+ }
470
+
478
471
  const messagePayload: {
479
472
  conversation_id: string;
480
473
  message_id: string;
@@ -492,6 +485,10 @@ async function attemptSend(entry: OutboxEntry): Promise<void> {
492
485
  messagePayload.recipient_id = entry.recipientId;
493
486
  }
494
487
 
488
+ state.log.info(
489
+ `[mixin] attempt send: transport=rest, jobId=${entry.jobId}, messageId=${entry.messageId}, conversation=${entry.conversationId}, recipient=${messagePayload.recipient_id ?? "none"}, category=${entry.category}`,
490
+ );
491
+
495
492
  await client.message.sendOne(messagePayload);
496
493
  }
497
494
 
package/src/shared.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { MixinApi } from "@mixin.dev/mixin-node-sdk";
2
+ import type { MixinAccountConfig } from "./config-schema.js";
3
+ import { buildRequestConfig } from "./proxy.js";
4
+
5
+ export type SendLog = {
6
+ info: (msg: string) => void;
7
+ warn: (msg: string) => void;
8
+ error: (msg: string, err?: unknown) => void;
9
+ };
10
+
11
+ export function sleep(ms: number): Promise<void> {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+
15
+ export function buildClient(config: MixinAccountConfig) {
16
+ return MixinApi({
17
+ keystore: {
18
+ app_id: config.appId!,
19
+ session_id: config.sessionId!,
20
+ server_public_key: config.serverPublicKey!,
21
+ session_private_key: config.sessionPrivateKey!,
22
+ },
23
+ requestConfig: buildRequestConfig(config.proxy),
24
+ });
25
+ }