@invago/mixin 1.0.8 → 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
+ }
@@ -0,0 +1,216 @@
1
+ import type { ReplyPayload } from "openclaw/plugin-sdk";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import { createMixinCollectOrder, formatMixpayOrderSummary } from "./mixpay-worker.js";
4
+ import { buildMixinReplyPlan, resolveMixinReplyPlan } from "./reply-format.js";
5
+ import {
6
+ sendAudioMessage,
7
+ sendButtonGroupMessage,
8
+ sendCardMessage,
9
+ sendFileMessage,
10
+ sendPostMessage,
11
+ sendTextMessage,
12
+ } from "./send-service.js";
13
+ import type { SendLog } from "./shared.js";
14
+
15
+ export type MixinOutboundStep =
16
+ | { kind: "text"; text: string }
17
+ | { kind: "post"; text: string }
18
+ | { kind: "file"; file: Parameters<typeof sendFileMessage>[4] }
19
+ | { kind: "audio"; audio: Parameters<typeof sendAudioMessage>[4] }
20
+ | { kind: "collect"; collect: Parameters<typeof createMixinCollectOrder>[0]["request"] }
21
+ | { kind: "buttons"; intro?: string; buttons: Parameters<typeof sendButtonGroupMessage>[4] }
22
+ | { kind: "card"; card: Parameters<typeof sendCardMessage>[4] }
23
+ | { kind: "media-url"; mediaUrl: string };
24
+
25
+ export type MixinOutboundPlan = {
26
+ steps: MixinOutboundStep[];
27
+ warnings: string[];
28
+ };
29
+
30
+ function appendReplyTextPlan(
31
+ steps: MixinOutboundStep[],
32
+ warnings: string[],
33
+ text: string,
34
+ options?: {
35
+ allowAttachmentTemplates?: boolean;
36
+ },
37
+ ): void {
38
+ const resolution = resolveMixinReplyPlan(text);
39
+ if (resolution.matchedTemplate && !resolution.plan) {
40
+ steps.push({
41
+ kind: "text",
42
+ text: `Mixin template error: ${resolution.error ?? "invalid template"}`,
43
+ });
44
+ return;
45
+ }
46
+
47
+ const plan = resolution.plan ?? buildMixinReplyPlan(text);
48
+ if (!plan) {
49
+ return;
50
+ }
51
+
52
+ if ((plan.kind === "file" || plan.kind === "audio") && options?.allowAttachmentTemplates === false) {
53
+ warnings.push(`ignored ${plan.kind} template because native media payload already contains media`);
54
+ steps.push({
55
+ kind: "text",
56
+ text: `Mixin template warning: ${plan.kind} template was ignored because mediaUrl/mediaUrls is already present.`,
57
+ });
58
+ return;
59
+ }
60
+
61
+ if (plan.kind === "text") {
62
+ steps.push({ kind: "text", text: plan.text });
63
+ return;
64
+ }
65
+ if (plan.kind === "post") {
66
+ steps.push({ kind: "post", text: plan.text });
67
+ return;
68
+ }
69
+ if (plan.kind === "file") {
70
+ steps.push({ kind: "file", file: plan.file });
71
+ return;
72
+ }
73
+ if (plan.kind === "audio") {
74
+ steps.push({ kind: "audio", audio: plan.audio });
75
+ return;
76
+ }
77
+ if (plan.kind === "collect") {
78
+ steps.push({ kind: "collect", collect: plan.collect });
79
+ return;
80
+ }
81
+ if (plan.kind === "buttons") {
82
+ steps.push({ kind: "buttons", intro: plan.intro, buttons: plan.buttons });
83
+ return;
84
+ }
85
+ steps.push({ kind: "card", card: plan.card });
86
+ }
87
+
88
+ export function buildMixinOutboundPlanFromReplyText(text: string): MixinOutboundPlan {
89
+ const steps: MixinOutboundStep[] = [];
90
+ const warnings: string[] = [];
91
+ appendReplyTextPlan(steps, warnings, text, { allowAttachmentTemplates: true });
92
+ return { steps, warnings };
93
+ }
94
+
95
+ export function buildMixinOutboundPlanFromReplyPayload(payload: ReplyPayload): MixinOutboundPlan {
96
+ const steps: MixinOutboundStep[] = [];
97
+ const warnings: string[] = [];
98
+ const mediaUrls = payload.mediaUrls && payload.mediaUrls.length > 0
99
+ ? payload.mediaUrls
100
+ : payload.mediaUrl
101
+ ? [payload.mediaUrl]
102
+ : [];
103
+
104
+ if (payload.text?.trim()) {
105
+ appendReplyTextPlan(steps, warnings, payload.text, {
106
+ allowAttachmentTemplates: mediaUrls.length === 0,
107
+ });
108
+ }
109
+
110
+ for (const mediaUrl of mediaUrls) {
111
+ steps.push({ kind: "media-url", mediaUrl });
112
+ }
113
+
114
+ return { steps, warnings };
115
+ }
116
+
117
+ export async function executeMixinOutboundPlan(params: {
118
+ cfg: OpenClawConfig;
119
+ accountId: string;
120
+ conversationId: string;
121
+ recipientId?: string;
122
+ creatorId?: string;
123
+ steps: MixinOutboundStep[];
124
+ log?: SendLog;
125
+ sendMediaUrl?: (mediaUrl: string) => Promise<string | undefined>;
126
+ }): Promise<string | undefined> {
127
+ const { cfg, accountId, conversationId, recipientId, creatorId, steps, log, sendMediaUrl } = params;
128
+ let lastMessageId: string | undefined;
129
+
130
+ for (const step of steps) {
131
+ if (step.kind === "text") {
132
+ const result = await sendTextMessage(cfg, accountId, conversationId, recipientId, step.text, log);
133
+ if (!result.ok) {
134
+ throw new Error(result.error ?? "mixin outbound text send failed");
135
+ }
136
+ lastMessageId = result.messageId ?? lastMessageId;
137
+ continue;
138
+ }
139
+
140
+ if (step.kind === "post") {
141
+ const result = await sendPostMessage(cfg, accountId, conversationId, recipientId, step.text, log);
142
+ if (!result.ok) {
143
+ throw new Error(result.error ?? "mixin outbound post send failed");
144
+ }
145
+ lastMessageId = result.messageId ?? lastMessageId;
146
+ continue;
147
+ }
148
+
149
+ if (step.kind === "file") {
150
+ const result = await sendFileMessage(cfg, accountId, conversationId, recipientId, step.file, log);
151
+ if (!result.ok) {
152
+ throw new Error(result.error ?? "mixin outbound file send failed");
153
+ }
154
+ lastMessageId = result.messageId ?? lastMessageId;
155
+ continue;
156
+ }
157
+
158
+ if (step.kind === "audio") {
159
+ const result = await sendAudioMessage(cfg, accountId, conversationId, recipientId, step.audio, log);
160
+ if (!result.ok) {
161
+ throw new Error(result.error ?? "mixin outbound audio send failed");
162
+ }
163
+ lastMessageId = result.messageId ?? lastMessageId;
164
+ continue;
165
+ }
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
+
184
+ if (step.kind === "buttons") {
185
+ if (step.intro) {
186
+ const introResult = await sendTextMessage(cfg, accountId, conversationId, recipientId, step.intro, log);
187
+ if (!introResult.ok) {
188
+ throw new Error(introResult.error ?? "mixin outbound intro send failed");
189
+ }
190
+ lastMessageId = introResult.messageId ?? lastMessageId;
191
+ }
192
+ const result = await sendButtonGroupMessage(cfg, accountId, conversationId, recipientId, step.buttons, log);
193
+ if (!result.ok) {
194
+ throw new Error(result.error ?? "mixin outbound buttons send failed");
195
+ }
196
+ lastMessageId = result.messageId ?? lastMessageId;
197
+ continue;
198
+ }
199
+
200
+ if (step.kind === "card") {
201
+ const result = await sendCardMessage(cfg, accountId, conversationId, recipientId, step.card, log);
202
+ if (!result.ok) {
203
+ throw new Error(result.error ?? "mixin outbound card send failed");
204
+ }
205
+ lastMessageId = result.messageId ?? lastMessageId;
206
+ continue;
207
+ }
208
+
209
+ if (!sendMediaUrl) {
210
+ throw new Error("mixin outbound mediaUrl handler not configured");
211
+ }
212
+ lastMessageId = await sendMediaUrl(step.mediaUrl) ?? lastMessageId;
213
+ }
214
+
215
+ return lastMessageId;
216
+ }