@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.
- package/README.md +262 -4
- package/README.zh-CN.md +328 -77
- package/package.json +79 -1
- package/src/blaze-service.ts +24 -7
- package/src/channel.ts +85 -8
- package/src/config-schema.ts +16 -0
- package/src/config.ts +5 -0
- package/src/crypto.ts +5 -0
- package/src/inbound-handler.ts +1205 -637
- package/src/mixpay-service.ts +211 -0
- package/src/mixpay-store.ts +205 -0
- package/src/mixpay-worker.ts +353 -0
- package/src/outbound-plan.ts +26 -7
- package/src/reply-format.ts +52 -1
- package/src/runtime.ts +26 -0
- package/src/send-service.ts +24 -27
- package/src/shared.ts +25 -0
- package/src/status.ts +14 -0
- package/src/decrypt.ts +0 -126
- package/tools/mixin-plugin-onboard/README.md +0 -98
- package/tools/mixin-plugin-onboard/bin/mixin-plugin-onboard.mjs +0 -3
- package/tools/mixin-plugin-onboard/src/commands/doctor.ts +0 -28
- package/tools/mixin-plugin-onboard/src/commands/info.ts +0 -23
- package/tools/mixin-plugin-onboard/src/commands/install.ts +0 -5
- package/tools/mixin-plugin-onboard/src/commands/update.ts +0 -5
- package/tools/mixin-plugin-onboard/src/index.ts +0 -49
- package/tools/mixin-plugin-onboard/src/utils.ts +0 -189
|
@@ -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
|
+
}
|
package/src/outbound-plan.ts
CHANGED
|
@@ -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);
|
package/src/reply-format.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/send-service.ts
CHANGED
|
@@ -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 {
|
|
10
|
-
import {
|
|
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):
|
|
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
|
+
}
|