@invago/mixin 1.0.7 → 1.0.8
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 +82 -3
- package/README.zh-CN.md +82 -3
- package/package.json +1 -1
- package/src/channel.ts +160 -8
- package/src/config-schema.ts +2 -0
- package/src/config.ts +1 -0
- package/src/inbound-handler.ts +403 -163
- package/src/reply-format.ts +55 -2
- package/src/send-service.ts +222 -14
package/src/reply-format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MixinButton, MixinCard } from "./send-service.js";
|
|
1
|
+
import type { MixinAudio, MixinButton, MixinCard, MixinFile } from "./send-service.js";
|
|
2
2
|
|
|
3
3
|
type LinkItem = {
|
|
4
4
|
label: string;
|
|
@@ -8,6 +8,8 @@ type LinkItem = {
|
|
|
8
8
|
export type MixinReplyPlan =
|
|
9
9
|
| { kind: "text"; text: string }
|
|
10
10
|
| { kind: "post"; text: string }
|
|
11
|
+
| { kind: "file"; file: MixinFile }
|
|
12
|
+
| { kind: "audio"; audio: MixinAudio }
|
|
11
13
|
| { kind: "buttons"; intro?: string; buttons: MixinButton[] }
|
|
12
14
|
| { kind: "card"; card: MixinCard };
|
|
13
15
|
|
|
@@ -15,7 +17,7 @@ const MAX_BUTTONS = 6;
|
|
|
15
17
|
const MAX_BUTTON_LABEL = 36;
|
|
16
18
|
const MAX_CARD_TITLE = 36;
|
|
17
19
|
const MAX_CARD_DESCRIPTION = 120;
|
|
18
|
-
const TEMPLATE_REGEX = /^```mixin-(text|post|buttons|card)\s*\n([\s\S]*?)\n```$/i;
|
|
20
|
+
const TEMPLATE_REGEX = /^```mixin-(text|post|buttons|card|file|audio)\s*\n([\s\S]*?)\n```$/i;
|
|
19
21
|
|
|
20
22
|
function truncate(value: string, limit: number): string {
|
|
21
23
|
return value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 3))}...`;
|
|
@@ -121,6 +123,49 @@ function parseCardTemplate(body: string): MixinReplyPlan | null {
|
|
|
121
123
|
};
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
function parseFileTemplate(body: string): MixinReplyPlan | null {
|
|
127
|
+
const parsed = parseJsonTemplate<MixinFile>(body);
|
|
128
|
+
if (!parsed || typeof parsed.filePath !== "string") {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const filePath = normalizeWhitespace(parsed.filePath);
|
|
133
|
+
if (!filePath) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
kind: "file",
|
|
139
|
+
file: {
|
|
140
|
+
filePath,
|
|
141
|
+
fileName: typeof parsed.fileName === "string" ? normalizeWhitespace(parsed.fileName) : undefined,
|
|
142
|
+
mimeType: typeof parsed.mimeType === "string" ? normalizeWhitespace(parsed.mimeType) : undefined,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseAudioTemplate(body: string): MixinReplyPlan | null {
|
|
148
|
+
const parsed = parseJsonTemplate<MixinAudio>(body);
|
|
149
|
+
if (!parsed || typeof parsed.filePath !== "string" || typeof parsed.duration !== "number") {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const filePath = normalizeWhitespace(parsed.filePath);
|
|
154
|
+
if (!filePath || !Number.isFinite(parsed.duration) || parsed.duration <= 0) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
kind: "audio",
|
|
160
|
+
audio: {
|
|
161
|
+
filePath,
|
|
162
|
+
duration: parsed.duration,
|
|
163
|
+
mimeType: typeof parsed.mimeType === "string" ? normalizeWhitespace(parsed.mimeType) : undefined,
|
|
164
|
+
waveForm: typeof parsed.waveForm === "string" ? normalizeWhitespace(parsed.waveForm) : undefined,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
124
169
|
function parseExplicitTemplate(text: string): MixinReplyPlan | null {
|
|
125
170
|
const match = text.match(TEMPLATE_REGEX);
|
|
126
171
|
if (!match) {
|
|
@@ -146,6 +191,14 @@ function parseExplicitTemplate(text: string): MixinReplyPlan | null {
|
|
|
146
191
|
return parseCardTemplate(body);
|
|
147
192
|
}
|
|
148
193
|
|
|
194
|
+
if (templateType === "file") {
|
|
195
|
+
return parseFileTemplate(body);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (templateType === "audio") {
|
|
199
|
+
return parseAudioTemplate(body);
|
|
200
|
+
}
|
|
201
|
+
|
|
149
202
|
return null;
|
|
150
203
|
}
|
|
151
204
|
|
package/src/send-service.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
1
5
|
import { MixinApi } from "@mixin.dev/mixin-node-sdk";
|
|
2
6
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
7
|
import type { MixinAccountConfig } from "./config-schema.js";
|
|
4
8
|
import { getAccountConfig } from "./config.js";
|
|
5
9
|
import { buildRequestConfig } from "./proxy.js";
|
|
6
|
-
import
|
|
7
|
-
import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
8
|
-
import path from "path";
|
|
10
|
+
import { getMixinRuntime } from "./runtime.js";
|
|
9
11
|
|
|
10
12
|
const BASE_DELAY = 1000;
|
|
11
13
|
const MAX_DELAY = 60_000;
|
|
12
14
|
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
15
|
const MAX_ERROR_LENGTH = 500;
|
|
17
16
|
const MAX_OUTBOX_FILE_BYTES = 10 * 1024 * 1024;
|
|
18
17
|
|
|
@@ -25,6 +24,8 @@ type SendLog = {
|
|
|
25
24
|
export type MixinSupportedMessageCategory =
|
|
26
25
|
| "PLAIN_TEXT"
|
|
27
26
|
| "PLAIN_POST"
|
|
27
|
+
| "PLAIN_AUDIO"
|
|
28
|
+
| "PLAIN_DATA"
|
|
28
29
|
| "APP_BUTTON_GROUP"
|
|
29
30
|
| "APP_CARD";
|
|
30
31
|
|
|
@@ -44,6 +45,34 @@ export interface MixinCard {
|
|
|
44
45
|
shareable?: boolean;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
export interface MixinFile {
|
|
49
|
+
filePath: string;
|
|
50
|
+
fileName?: string;
|
|
51
|
+
mimeType?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MixinAudio {
|
|
55
|
+
filePath: string;
|
|
56
|
+
mimeType?: string;
|
|
57
|
+
duration: number;
|
|
58
|
+
waveForm?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface FileOutboxBody {
|
|
62
|
+
kind: "file";
|
|
63
|
+
filePath: string;
|
|
64
|
+
fileName: string;
|
|
65
|
+
mimeType: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface AudioOutboxBody {
|
|
69
|
+
kind: "audio";
|
|
70
|
+
filePath: string;
|
|
71
|
+
mimeType: string;
|
|
72
|
+
duration: number;
|
|
73
|
+
waveForm?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
interface OutboxEntry {
|
|
48
77
|
jobId: string;
|
|
49
78
|
accountId: string;
|
|
@@ -93,6 +122,7 @@ const state: {
|
|
|
93
122
|
log: SendLog;
|
|
94
123
|
loaded: boolean;
|
|
95
124
|
started: boolean;
|
|
125
|
+
outboxPathLogged: boolean;
|
|
96
126
|
entries: OutboxEntry[];
|
|
97
127
|
persistChain: Promise<void>;
|
|
98
128
|
wakeRequested: boolean;
|
|
@@ -102,6 +132,7 @@ const state: {
|
|
|
102
132
|
log: fallbackLog,
|
|
103
133
|
loaded: false,
|
|
104
134
|
started: false,
|
|
135
|
+
outboxPathLogged: false,
|
|
105
136
|
entries: [],
|
|
106
137
|
persistChain: Promise.resolve(),
|
|
107
138
|
wakeRequested: false,
|
|
@@ -124,6 +155,32 @@ function buildClient(config: MixinAccountConfig) {
|
|
|
124
155
|
});
|
|
125
156
|
}
|
|
126
157
|
|
|
158
|
+
function guessMimeType(fileName: string): string {
|
|
159
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
160
|
+
switch (ext) {
|
|
161
|
+
case ".txt":
|
|
162
|
+
return "text/plain";
|
|
163
|
+
case ".md":
|
|
164
|
+
return "text/markdown";
|
|
165
|
+
case ".json":
|
|
166
|
+
return "application/json";
|
|
167
|
+
case ".pdf":
|
|
168
|
+
return "application/pdf";
|
|
169
|
+
case ".zip":
|
|
170
|
+
return "application/zip";
|
|
171
|
+
case ".csv":
|
|
172
|
+
return "text/csv";
|
|
173
|
+
case ".ogg":
|
|
174
|
+
return "audio/ogg";
|
|
175
|
+
case ".mp3":
|
|
176
|
+
return "audio/mpeg";
|
|
177
|
+
case ".wav":
|
|
178
|
+
return "audio/wav";
|
|
179
|
+
default:
|
|
180
|
+
return "application/octet-stream";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
127
184
|
function computeNextDelay(attempts: number): number {
|
|
128
185
|
return Math.min(BASE_DELAY * Math.pow(MULTIPLIER, Math.max(0, attempts)), MAX_DELAY);
|
|
129
186
|
}
|
|
@@ -135,6 +192,44 @@ function updateRuntime(cfg: OpenClawConfig, log?: SendLog): void {
|
|
|
135
192
|
}
|
|
136
193
|
}
|
|
137
194
|
|
|
195
|
+
function resolveFallbackOutboxDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
196
|
+
const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
|
197
|
+
if (stateOverride) {
|
|
198
|
+
return path.join(stateOverride, "mixin");
|
|
199
|
+
}
|
|
200
|
+
const openClawHome = env.OPENCLAW_HOME?.trim();
|
|
201
|
+
if (openClawHome) {
|
|
202
|
+
return path.join(openClawHome, ".openclaw", "mixin");
|
|
203
|
+
}
|
|
204
|
+
return path.join(os.homedir(), ".openclaw", "mixin");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolveOutboxDir(): string {
|
|
208
|
+
try {
|
|
209
|
+
return path.join(getMixinRuntime().state.resolveStateDir(process.env, os.homedir), "mixin");
|
|
210
|
+
} catch (err) {
|
|
211
|
+
const fallbackDir = resolveFallbackOutboxDir();
|
|
212
|
+
state.log.warn(
|
|
213
|
+
`[mixin] failed to resolve OpenClaw state dir, falling back to ${fallbackDir}: ${err instanceof Error ? err.message : String(err)}`,
|
|
214
|
+
);
|
|
215
|
+
return fallbackDir;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveOutboxPaths(): {
|
|
220
|
+
outboxDir: string;
|
|
221
|
+
outboxFile: string;
|
|
222
|
+
outboxTmpFile: string;
|
|
223
|
+
} {
|
|
224
|
+
const outboxDir = resolveOutboxDir();
|
|
225
|
+
const outboxFile = path.join(outboxDir, "mixin-outbox.json");
|
|
226
|
+
return {
|
|
227
|
+
outboxDir,
|
|
228
|
+
outboxFile,
|
|
229
|
+
outboxTmpFile: `${outboxFile}.tmp`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
138
233
|
function normalizeErrorMessage(message: string): string {
|
|
139
234
|
if (message.length <= MAX_ERROR_LENGTH) {
|
|
140
235
|
return message;
|
|
@@ -171,17 +266,74 @@ function normalizeEntry(entry: OutboxEntry): OutboxEntry {
|
|
|
171
266
|
};
|
|
172
267
|
}
|
|
173
268
|
|
|
269
|
+
function isStructuredBody(body: string): body is string {
|
|
270
|
+
return body.trim().startsWith("{");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseFileBody(body: string): FileOutboxBody {
|
|
274
|
+
const parsed = JSON.parse(body) as Partial<FileOutboxBody>;
|
|
275
|
+
if (parsed.kind !== "file" || !parsed.filePath || !parsed.fileName || !parsed.mimeType) {
|
|
276
|
+
throw new Error("invalid file outbox body");
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
kind: "file",
|
|
280
|
+
filePath: String(parsed.filePath),
|
|
281
|
+
fileName: String(parsed.fileName),
|
|
282
|
+
mimeType: String(parsed.mimeType),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function buildAttachmentPayload(
|
|
287
|
+
client: ReturnType<typeof buildClient>,
|
|
288
|
+
filePath: string,
|
|
289
|
+
fileName: string,
|
|
290
|
+
mimeType: string,
|
|
291
|
+
): Promise<string> {
|
|
292
|
+
const buffer = await readFile(filePath);
|
|
293
|
+
const file = new File([buffer], fileName, { type: mimeType });
|
|
294
|
+
const uploaded = await client.attachment.upload(file);
|
|
295
|
+
const fileInfo = await stat(filePath);
|
|
296
|
+
|
|
297
|
+
return JSON.stringify({
|
|
298
|
+
attachment_id: uploaded.attachment_id,
|
|
299
|
+
mime_type: mimeType,
|
|
300
|
+
size: fileInfo.size,
|
|
301
|
+
name: fileName,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function buildAudioAttachmentPayload(
|
|
306
|
+
client: ReturnType<typeof buildClient>,
|
|
307
|
+
body: AudioOutboxBody,
|
|
308
|
+
): Promise<string> {
|
|
309
|
+
const fileName = path.basename(body.filePath);
|
|
310
|
+
const buffer = await readFile(body.filePath);
|
|
311
|
+
const file = new File([buffer], fileName, { type: body.mimeType });
|
|
312
|
+
const uploaded = await client.attachment.upload(file);
|
|
313
|
+
const fileInfo = await stat(body.filePath);
|
|
314
|
+
|
|
315
|
+
return JSON.stringify({
|
|
316
|
+
attachment_id: uploaded.attachment_id,
|
|
317
|
+
mime_type: body.mimeType,
|
|
318
|
+
size: fileInfo.size,
|
|
319
|
+
duration: body.duration,
|
|
320
|
+
wave_form: body.waveForm,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
174
324
|
async function cleanupOutboxTmpFile(): Promise<void> {
|
|
325
|
+
const { outboxTmpFile } = resolveOutboxPaths();
|
|
175
326
|
try {
|
|
176
|
-
await rm(
|
|
327
|
+
await rm(outboxTmpFile, { force: true });
|
|
177
328
|
} catch (err) {
|
|
178
329
|
state.log.warn(`[mixin] failed to remove stale outbox tmp file: ${err instanceof Error ? err.message : String(err)}`);
|
|
179
330
|
}
|
|
180
331
|
}
|
|
181
332
|
|
|
182
333
|
async function warnIfOutboxFileTooLarge(): Promise<void> {
|
|
334
|
+
const { outboxFile } = resolveOutboxPaths();
|
|
183
335
|
try {
|
|
184
|
-
const info = await stat(
|
|
336
|
+
const info = await stat(outboxFile);
|
|
185
337
|
if (info.size > MAX_OUTBOX_FILE_BYTES) {
|
|
186
338
|
state.log.warn(`[mixin] outbox file is large: bytes=${info.size}, pending=${state.entries.length}`);
|
|
187
339
|
}
|
|
@@ -198,11 +350,16 @@ async function ensureOutboxLoaded(): Promise<void> {
|
|
|
198
350
|
return;
|
|
199
351
|
}
|
|
200
352
|
|
|
201
|
-
|
|
353
|
+
const { outboxDir, outboxFile } = resolveOutboxPaths();
|
|
354
|
+
if (!state.outboxPathLogged) {
|
|
355
|
+
state.log.info(`[mixin] outbox path: dir=${outboxDir}, file=${outboxFile}`);
|
|
356
|
+
state.outboxPathLogged = true;
|
|
357
|
+
}
|
|
358
|
+
await mkdir(outboxDir, { recursive: true });
|
|
202
359
|
await cleanupOutboxTmpFile();
|
|
203
360
|
|
|
204
361
|
try {
|
|
205
|
-
const raw = await readFile(
|
|
362
|
+
const raw = await readFile(outboxFile, "utf-8");
|
|
206
363
|
const parsed = JSON.parse(raw) as OutboxEntry[];
|
|
207
364
|
state.entries = Array.isArray(parsed)
|
|
208
365
|
? parsed.map((entry) => normalizeEntry(entry))
|
|
@@ -228,10 +385,11 @@ function queuePersist(task: () => Promise<void>): Promise<void> {
|
|
|
228
385
|
|
|
229
386
|
async function persistEntries(): Promise<void> {
|
|
230
387
|
await queuePersist(async () => {
|
|
231
|
-
|
|
388
|
+
const { outboxDir, outboxFile, outboxTmpFile } = resolveOutboxPaths();
|
|
389
|
+
await mkdir(outboxDir, { recursive: true });
|
|
232
390
|
const payload = JSON.stringify(state.entries, null, 2);
|
|
233
|
-
await writeFile(
|
|
234
|
-
await rename(
|
|
391
|
+
await writeFile(outboxTmpFile, payload, "utf-8");
|
|
392
|
+
await rename(outboxTmpFile, outboxFile);
|
|
235
393
|
await warnIfOutboxFileTooLarge();
|
|
236
394
|
});
|
|
237
395
|
}
|
|
@@ -296,6 +454,16 @@ async function attemptSend(entry: OutboxEntry): Promise<void> {
|
|
|
296
454
|
}
|
|
297
455
|
|
|
298
456
|
const client = buildClient(config);
|
|
457
|
+
let payloadBody = entry.body;
|
|
458
|
+
if (entry.category === "PLAIN_DATA" && isStructuredBody(entry.body)) {
|
|
459
|
+
const body = parseFileBody(entry.body);
|
|
460
|
+
payloadBody = await buildAttachmentPayload(client, body.filePath, body.fileName, body.mimeType);
|
|
461
|
+
} else if (entry.category === "PLAIN_AUDIO" && isStructuredBody(entry.body)) {
|
|
462
|
+
const body = JSON.parse(entry.body) as AudioOutboxBody;
|
|
463
|
+
payloadBody = await buildAudioAttachmentPayload(client, body);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const dataBase64 = Buffer.from(payloadBody).toString("base64");
|
|
299
467
|
const messagePayload: {
|
|
300
468
|
conversation_id: string;
|
|
301
469
|
message_id: string;
|
|
@@ -306,7 +474,7 @@ async function attemptSend(entry: OutboxEntry): Promise<void> {
|
|
|
306
474
|
conversation_id: entry.conversationId,
|
|
307
475
|
message_id: entry.messageId,
|
|
308
476
|
category: entry.category,
|
|
309
|
-
data_base64:
|
|
477
|
+
data_base64: dataBase64,
|
|
310
478
|
};
|
|
311
479
|
|
|
312
480
|
if (entry.recipientId) {
|
|
@@ -461,6 +629,46 @@ export async function sendPostMessage(
|
|
|
461
629
|
return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_POST", text, log);
|
|
462
630
|
}
|
|
463
631
|
|
|
632
|
+
export async function sendFileMessage(
|
|
633
|
+
cfg: OpenClawConfig,
|
|
634
|
+
accountId: string,
|
|
635
|
+
conversationId: string,
|
|
636
|
+
recipientId: string | undefined,
|
|
637
|
+
file: MixinFile,
|
|
638
|
+
log?: SendLog,
|
|
639
|
+
): Promise<SendResult> {
|
|
640
|
+
const fileName = file.fileName?.trim() || path.basename(file.filePath);
|
|
641
|
+
const mimeType = file.mimeType?.trim() || guessMimeType(fileName);
|
|
642
|
+
const body = JSON.stringify({
|
|
643
|
+
kind: "file",
|
|
644
|
+
filePath: file.filePath,
|
|
645
|
+
fileName,
|
|
646
|
+
mimeType,
|
|
647
|
+
} satisfies FileOutboxBody);
|
|
648
|
+
|
|
649
|
+
return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_DATA", body, log);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export async function sendAudioMessage(
|
|
653
|
+
cfg: OpenClawConfig,
|
|
654
|
+
accountId: string,
|
|
655
|
+
conversationId: string,
|
|
656
|
+
recipientId: string | undefined,
|
|
657
|
+
audio: MixinAudio,
|
|
658
|
+
log?: SendLog,
|
|
659
|
+
): Promise<SendResult> {
|
|
660
|
+
const mimeType = audio.mimeType?.trim() || guessMimeType(audio.filePath);
|
|
661
|
+
const body = JSON.stringify({
|
|
662
|
+
kind: "audio",
|
|
663
|
+
filePath: audio.filePath,
|
|
664
|
+
mimeType,
|
|
665
|
+
duration: audio.duration,
|
|
666
|
+
waveForm: audio.waveForm,
|
|
667
|
+
} satisfies AudioOutboxBody);
|
|
668
|
+
|
|
669
|
+
return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_AUDIO", body, log);
|
|
670
|
+
}
|
|
671
|
+
|
|
464
672
|
export async function sendButtonGroupMessage(
|
|
465
673
|
cfg: OpenClawConfig,
|
|
466
674
|
accountId: string,
|