@invago/mixin 1.0.8 → 1.0.9
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 +67 -1
- package/README.zh-CN.md +67 -1
- package/package.json +1 -1
- package/src/channel.ts +101 -35
- package/src/config-schema.ts +20 -1
- package/src/config.ts +98 -10
- package/src/inbound-handler.ts +108 -47
- package/src/outbound-plan.ts +197 -0
- package/src/reply-format.ts +37 -23
- package/src/send-service.ts +11 -0
- package/src/status.ts +100 -0
- package/tools/mixin-plugin-onboard/README.md +98 -0
- package/tools/mixin-plugin-onboard/bin/mixin-plugin-onboard.mjs +3 -0
- package/tools/mixin-plugin-onboard/src/commands/doctor.ts +28 -0
- package/tools/mixin-plugin-onboard/src/commands/info.ts +23 -0
- package/tools/mixin-plugin-onboard/src/commands/install.ts +5 -0
- package/tools/mixin-plugin-onboard/src/commands/update.ts +5 -0
- package/tools/mixin-plugin-onboard/src/index.ts +49 -0
- package/tools/mixin-plugin-onboard/src/utils.ts +189 -0
package/src/config.ts
CHANGED
|
@@ -1,26 +1,60 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MixinAccountConfigSchema,
|
|
4
|
+
MixinConversationConfigSchema,
|
|
5
|
+
type MixinAccountConfig,
|
|
6
|
+
type MixinConversationConfig,
|
|
7
|
+
} from "./config-schema.js";
|
|
3
8
|
|
|
4
|
-
|
|
5
|
-
|
|
9
|
+
type RawMixinConfig = Partial<MixinAccountConfig> & {
|
|
10
|
+
defaultAccount?: string;
|
|
11
|
+
accounts?: Record<string, Partial<MixinAccountConfig> | undefined>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function getRawConfig(cfg: OpenClawConfig): RawMixinConfig {
|
|
15
|
+
return ((cfg.channels as Record<string, unknown>)?.mixin ?? {}) as RawMixinConfig;
|
|
6
16
|
}
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
function hasTopLevelAccountConfig(raw: RawMixinConfig): boolean {
|
|
19
|
+
return Boolean(raw.appId || raw.sessionId || raw.serverPublicKey || raw.sessionPrivateKey || raw.name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveDefaultAccountId(cfg: OpenClawConfig): string {
|
|
9
23
|
const raw = getRawConfig(cfg);
|
|
24
|
+
const configuredDefault = raw.defaultAccount?.trim();
|
|
25
|
+
if (configuredDefault && raw.accounts?.[configuredDefault]) {
|
|
26
|
+
return configuredDefault;
|
|
27
|
+
}
|
|
28
|
+
if (configuredDefault === "default") {
|
|
29
|
+
return "default";
|
|
30
|
+
}
|
|
10
31
|
if (raw.accounts && Object.keys(raw.accounts).length > 0) {
|
|
11
|
-
|
|
32
|
+
if (hasTopLevelAccountConfig(raw)) {
|
|
33
|
+
return "default";
|
|
34
|
+
}
|
|
35
|
+
return Object.keys(raw.accounts)[0] ?? "default";
|
|
12
36
|
}
|
|
13
|
-
return
|
|
37
|
+
return "default";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function listAccountIds(cfg: OpenClawConfig): string[] {
|
|
41
|
+
const raw = getRawConfig(cfg);
|
|
42
|
+
const accountIds = raw.accounts ? Object.keys(raw.accounts) : [];
|
|
43
|
+
if (hasTopLevelAccountConfig(raw) || accountIds.length === 0) {
|
|
44
|
+
return ["default", ...accountIds.filter((accountId) => accountId !== "default")];
|
|
45
|
+
}
|
|
46
|
+
return accountIds;
|
|
14
47
|
}
|
|
15
48
|
|
|
16
49
|
export function getAccountConfig(cfg: OpenClawConfig, accountId?: string): MixinAccountConfig {
|
|
17
50
|
const raw = getRawConfig(cfg);
|
|
51
|
+
const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg);
|
|
18
52
|
let accountRaw: Partial<MixinAccountConfig>;
|
|
19
53
|
|
|
20
|
-
if (
|
|
21
|
-
accountRaw = raw.accounts[
|
|
54
|
+
if (resolvedAccountId !== "default" && raw.accounts?.[resolvedAccountId]) {
|
|
55
|
+
accountRaw = raw.accounts[resolvedAccountId] as Partial<MixinAccountConfig>;
|
|
22
56
|
} else {
|
|
23
|
-
accountRaw = raw
|
|
57
|
+
accountRaw = raw;
|
|
24
58
|
}
|
|
25
59
|
|
|
26
60
|
const result = MixinAccountConfigSchema.safeParse(accountRaw);
|
|
@@ -29,7 +63,7 @@ export function getAccountConfig(cfg: OpenClawConfig, accountId?: string): Mixin
|
|
|
29
63
|
}
|
|
30
64
|
|
|
31
65
|
export function resolveAccount(cfg: OpenClawConfig, accountId?: string) {
|
|
32
|
-
const id = accountId ??
|
|
66
|
+
const id = accountId ?? resolveDefaultAccountId(cfg);
|
|
33
67
|
const config = getAccountConfig(cfg, id);
|
|
34
68
|
const configured = Boolean(config.appId && config.sessionId && config.serverPublicKey && config.sessionPrivateKey);
|
|
35
69
|
return {
|
|
@@ -49,6 +83,60 @@ export function resolveAccount(cfg: OpenClawConfig, accountId?: string) {
|
|
|
49
83
|
};
|
|
50
84
|
}
|
|
51
85
|
|
|
86
|
+
export function resolveMediaMaxMb(cfg: OpenClawConfig, accountId?: string): number | undefined {
|
|
87
|
+
return getAccountConfig(cfg, accountId).mediaMaxMb;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getRawAccountConfig(cfg: OpenClawConfig, accountId?: string): Partial<MixinAccountConfig> {
|
|
91
|
+
const raw = getRawConfig(cfg);
|
|
92
|
+
const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg);
|
|
93
|
+
if (resolvedAccountId !== "default" && raw.accounts?.[resolvedAccountId]) {
|
|
94
|
+
return raw.accounts[resolvedAccountId] as Partial<MixinAccountConfig>;
|
|
95
|
+
}
|
|
96
|
+
return raw;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getConversationConfig(
|
|
100
|
+
cfg: OpenClawConfig,
|
|
101
|
+
accountId: string,
|
|
102
|
+
conversationId: string,
|
|
103
|
+
): {
|
|
104
|
+
exists: boolean;
|
|
105
|
+
config: MixinConversationConfig;
|
|
106
|
+
} {
|
|
107
|
+
const accountRaw = getRawAccountConfig(cfg, accountId);
|
|
108
|
+
const conversationRaw = accountRaw.conversations?.[conversationId] as Partial<MixinConversationConfig> | undefined;
|
|
109
|
+
const result = MixinConversationConfigSchema.safeParse(conversationRaw ?? {});
|
|
110
|
+
return {
|
|
111
|
+
exists: Boolean(conversationRaw),
|
|
112
|
+
config: result.success ? result.data : MixinConversationConfigSchema.parse({}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resolveConversationPolicy(
|
|
117
|
+
cfg: OpenClawConfig,
|
|
118
|
+
accountId: string,
|
|
119
|
+
conversationId: string,
|
|
120
|
+
): {
|
|
121
|
+
enabled: boolean;
|
|
122
|
+
requireMention: boolean;
|
|
123
|
+
mediaBypassMention: boolean;
|
|
124
|
+
groupPolicy: MixinAccountConfig["groupPolicy"];
|
|
125
|
+
groupAllowFrom: string[];
|
|
126
|
+
hasConversationOverride: boolean;
|
|
127
|
+
} {
|
|
128
|
+
const accountConfig = getAccountConfig(cfg, accountId);
|
|
129
|
+
const conversation = getConversationConfig(cfg, accountId, conversationId);
|
|
130
|
+
return {
|
|
131
|
+
enabled: conversation.config.enabled !== false,
|
|
132
|
+
requireMention: conversation.config.requireMention ?? accountConfig.requireMentionInGroup,
|
|
133
|
+
mediaBypassMention: conversation.config.mediaBypassMention ?? accountConfig.mediaBypassMentionInGroup,
|
|
134
|
+
groupPolicy: conversation.config.groupPolicy ?? accountConfig.groupPolicy,
|
|
135
|
+
groupAllowFrom: conversation.config.allowFrom ?? accountConfig.groupAllowFrom ?? [],
|
|
136
|
+
hasConversationOverride: conversation.exists,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
52
140
|
export function isConfigured(account: ReturnType<typeof resolveAccount>): boolean {
|
|
53
141
|
return account.configured;
|
|
54
142
|
}
|
package/src/inbound-handler.ts
CHANGED
|
@@ -2,22 +2,17 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { MixinApi } from "@mixin.dev/mixin-node-sdk";
|
|
5
|
-
import { buildAgentMediaPayload } from "openclaw/plugin-sdk";
|
|
5
|
+
import { buildAgentMediaPayload, evaluateSenderGroupAccess, resolveDefaultGroupPolicy } from "openclaw/plugin-sdk";
|
|
6
6
|
import type { AgentMediaPayload, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
7
|
-
import { getAccountConfig } from "./config.js";
|
|
7
|
+
import { getAccountConfig, resolveConversationPolicy } from "./config.js";
|
|
8
8
|
import type { MixinAccountConfig } from "./config-schema.js";
|
|
9
9
|
import { decryptMixinMessage } from "./crypto.js";
|
|
10
10
|
import { buildRequestConfig } from "./proxy.js";
|
|
11
|
-
import {
|
|
11
|
+
import { buildMixinOutboundPlanFromReplyText, executeMixinOutboundPlan } from "./outbound-plan.js";
|
|
12
12
|
import { getMixinRuntime } from "./runtime.js";
|
|
13
13
|
import {
|
|
14
|
-
sendAudioMessage,
|
|
15
14
|
getOutboxStatus,
|
|
16
15
|
purgePermanentInvalidOutboxEntries,
|
|
17
|
-
sendFileMessage,
|
|
18
|
-
sendButtonGroupMessage,
|
|
19
|
-
sendCardMessage,
|
|
20
|
-
sendPostMessage,
|
|
21
16
|
sendTextMessage,
|
|
22
17
|
} from "./send-service.js";
|
|
23
18
|
|
|
@@ -100,6 +95,14 @@ function buildClient(config: MixinAccountConfig) {
|
|
|
100
95
|
});
|
|
101
96
|
}
|
|
102
97
|
|
|
98
|
+
function resolveInboundMediaMaxBytes(config: MixinAccountConfig): number {
|
|
99
|
+
const mediaMaxMb = config.mediaMaxMb;
|
|
100
|
+
if (typeof mediaMaxMb === "number" && Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
|
|
101
|
+
return Math.max(1, Math.floor(mediaMaxMb * 1024 * 1024));
|
|
102
|
+
}
|
|
103
|
+
return INBOUND_MEDIA_MAX_BYTES;
|
|
104
|
+
}
|
|
105
|
+
|
|
103
106
|
function parseInboundAttachmentRequest(category: string, data: string): MixinAttachmentRequest | null {
|
|
104
107
|
if (category !== "PLAIN_DATA" && category !== "PLAIN_AUDIO") {
|
|
105
108
|
return null;
|
|
@@ -165,17 +168,18 @@ async function resolveInboundAttachment(params: {
|
|
|
165
168
|
|
|
166
169
|
try {
|
|
167
170
|
const client = buildClient(params.config);
|
|
171
|
+
const maxBytes = resolveInboundMediaMaxBytes(params.config);
|
|
168
172
|
const attachment = await client.attachment.fetch(payload.attachmentId);
|
|
169
173
|
const fetched = await params.rt.channel.media.fetchRemoteMedia({
|
|
170
174
|
url: attachment.view_url,
|
|
171
175
|
filePathHint: payload.fileName,
|
|
172
|
-
maxBytes
|
|
176
|
+
maxBytes,
|
|
173
177
|
});
|
|
174
178
|
const saved = await params.rt.channel.media.saveMediaBuffer(
|
|
175
179
|
fetched.buffer,
|
|
176
180
|
payload.mimeType ?? fetched.contentType,
|
|
177
181
|
"mixin",
|
|
178
|
-
|
|
182
|
+
maxBytes,
|
|
179
183
|
payload.fileName ?? fetched.fileName,
|
|
180
184
|
);
|
|
181
185
|
|
|
@@ -236,6 +240,10 @@ function normalizeAllowEntry(entry: string): string {
|
|
|
236
240
|
return entry.trim().toLowerCase();
|
|
237
241
|
}
|
|
238
242
|
|
|
243
|
+
function normalizeAllowEntries(entries: string[] | undefined): string[] {
|
|
244
|
+
return (entries ?? []).map(normalizeAllowEntry).filter(Boolean);
|
|
245
|
+
}
|
|
246
|
+
|
|
239
247
|
function resolveMixinAllowFromPaths(
|
|
240
248
|
rt: ReturnType<typeof getMixinRuntime>,
|
|
241
249
|
accountId: string,
|
|
@@ -290,41 +298,21 @@ async function deliverMixinReply(params: {
|
|
|
290
298
|
log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
|
|
291
299
|
}): Promise<void> {
|
|
292
300
|
const { cfg, accountId, conversationId, recipientId, text, log } = params;
|
|
293
|
-
const plan =
|
|
294
|
-
|
|
295
|
-
if (!plan) {
|
|
301
|
+
const plan = buildMixinOutboundPlanFromReplyText(text);
|
|
302
|
+
if (plan.steps.length === 0) {
|
|
296
303
|
return;
|
|
297
304
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (plan.kind === "post") {
|
|
305
|
-
await sendPostMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (plan.kind === "file") {
|
|
310
|
-
await sendFileMessage(cfg, accountId, conversationId, recipientId, plan.file, log);
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (plan.kind === "audio") {
|
|
315
|
-
await sendAudioMessage(cfg, accountId, conversationId, recipientId, plan.audio, log);
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (plan.kind === "buttons") {
|
|
320
|
-
if (plan.intro) {
|
|
321
|
-
await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.intro, log);
|
|
322
|
-
}
|
|
323
|
-
await sendButtonGroupMessage(cfg, accountId, conversationId, recipientId, plan.buttons, log);
|
|
324
|
-
return;
|
|
305
|
+
for (const warning of plan.warnings) {
|
|
306
|
+
log.warn(`[mixin] outbound plan warning: ${warning}`);
|
|
325
307
|
}
|
|
326
|
-
|
|
327
|
-
|
|
308
|
+
await executeMixinOutboundPlan({
|
|
309
|
+
cfg,
|
|
310
|
+
accountId,
|
|
311
|
+
conversationId,
|
|
312
|
+
recipientId,
|
|
313
|
+
steps: plan.steps,
|
|
314
|
+
log,
|
|
315
|
+
});
|
|
328
316
|
}
|
|
329
317
|
|
|
330
318
|
async function handleUnauthorizedDirectMessage(params: {
|
|
@@ -386,6 +374,46 @@ async function handleUnauthorizedDirectMessage(params: {
|
|
|
386
374
|
}
|
|
387
375
|
}
|
|
388
376
|
|
|
377
|
+
function evaluateMixinGroupAccess(params: {
|
|
378
|
+
cfg: OpenClawConfig;
|
|
379
|
+
config: MixinAccountConfig;
|
|
380
|
+
accountId: string;
|
|
381
|
+
conversationId: string;
|
|
382
|
+
senderId: string;
|
|
383
|
+
}): {
|
|
384
|
+
allowed: boolean;
|
|
385
|
+
reason: string;
|
|
386
|
+
groupPolicy: "open" | "disabled" | "allowlist";
|
|
387
|
+
groupAllowFrom: string[];
|
|
388
|
+
} {
|
|
389
|
+
const conversationPolicy = resolveConversationPolicy(params.cfg, params.accountId, params.conversationId);
|
|
390
|
+
if (!conversationPolicy.enabled) {
|
|
391
|
+
return {
|
|
392
|
+
allowed: false,
|
|
393
|
+
reason: "conversation disabled",
|
|
394
|
+
groupPolicy: "disabled",
|
|
395
|
+
groupAllowFrom: normalizeAllowEntries(conversationPolicy.groupAllowFrom),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const normalizedGroupAllowFrom = normalizeAllowEntries(conversationPolicy.groupAllowFrom);
|
|
400
|
+
const decision = evaluateSenderGroupAccess({
|
|
401
|
+
providerConfigPresent: true,
|
|
402
|
+
configuredGroupPolicy: conversationPolicy.groupPolicy,
|
|
403
|
+
defaultGroupPolicy: resolveDefaultGroupPolicy(params.cfg),
|
|
404
|
+
groupAllowFrom: normalizedGroupAllowFrom,
|
|
405
|
+
senderId: normalizeAllowEntry(params.senderId),
|
|
406
|
+
isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(normalizeAllowEntry(senderId)),
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
allowed: decision.allowed,
|
|
411
|
+
reason: decision.reason,
|
|
412
|
+
groupPolicy: decision.groupPolicy,
|
|
413
|
+
groupAllowFrom: normalizedGroupAllowFrom,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
389
417
|
export async function handleMixinMessage(params: {
|
|
390
418
|
cfg: OpenClawConfig;
|
|
391
419
|
accountId: string;
|
|
@@ -446,7 +474,19 @@ export async function handleMixinMessage(params: {
|
|
|
446
474
|
return;
|
|
447
475
|
}
|
|
448
476
|
|
|
449
|
-
|
|
477
|
+
const conversationPolicy = isDirect
|
|
478
|
+
? null
|
|
479
|
+
: resolveConversationPolicy(cfg, accountId, msg.conversationId);
|
|
480
|
+
|
|
481
|
+
if (
|
|
482
|
+
!isDirect &&
|
|
483
|
+
conversationPolicy &&
|
|
484
|
+
!(isAttachmentMessage && conversationPolicy.mediaBypassMention) &&
|
|
485
|
+
!shouldPassGroupFilter({
|
|
486
|
+
...config,
|
|
487
|
+
requireMentionInGroup: conversationPolicy.requireMention,
|
|
488
|
+
}, text)
|
|
489
|
+
) {
|
|
450
490
|
log.info(`[mixin] group message filtered: ${msg.messageId}`);
|
|
451
491
|
return;
|
|
452
492
|
}
|
|
@@ -454,10 +494,27 @@ export async function handleMixinMessage(params: {
|
|
|
454
494
|
const effectiveAllowFrom = await readEffectiveAllowFrom(rt, accountId, config.allowFrom, log);
|
|
455
495
|
const normalizedUserId = normalizeAllowEntry(msg.userId);
|
|
456
496
|
const dmPolicy = config.dmPolicy ?? "pairing";
|
|
457
|
-
const
|
|
497
|
+
const groupAccess = isDirect
|
|
498
|
+
? null
|
|
499
|
+
: evaluateMixinGroupAccess({
|
|
500
|
+
cfg,
|
|
501
|
+
config,
|
|
502
|
+
accountId,
|
|
503
|
+
conversationId: msg.conversationId,
|
|
504
|
+
senderId: msg.userId,
|
|
505
|
+
});
|
|
506
|
+
const isAuthorized = isDirect
|
|
507
|
+
? dmPolicy === "open" || effectiveAllowFrom.has(normalizedUserId)
|
|
508
|
+
: groupAccess?.allowed === true;
|
|
458
509
|
|
|
459
510
|
if (!isAuthorized) {
|
|
460
|
-
|
|
511
|
+
if (isDirect) {
|
|
512
|
+
log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
|
|
513
|
+
} else {
|
|
514
|
+
log.warn(
|
|
515
|
+
`[mixin] group sender ${msg.userId} blocked: conversationId=${msg.conversationId}, groupPolicy=${groupAccess?.groupPolicy ?? "unknown"}, reason=${groupAccess?.reason ?? "unknown"}`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
461
518
|
markProcessed(msg.messageId);
|
|
462
519
|
if (isDirect) {
|
|
463
520
|
await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
|
|
@@ -507,14 +564,18 @@ export async function handleMixinMessage(params: {
|
|
|
507
564
|
|
|
508
565
|
const shouldComputeCommandAuthorized = rt.channel.commands.shouldComputeCommandAuthorized(text, cfg);
|
|
509
566
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
510
|
-
const senderAllowedForCommands = useAccessGroups
|
|
567
|
+
const senderAllowedForCommands = useAccessGroups
|
|
568
|
+
? isDirect
|
|
569
|
+
? effectiveAllowFrom.has(normalizedUserId)
|
|
570
|
+
: groupAccess?.allowed === true
|
|
571
|
+
: true;
|
|
511
572
|
|
|
512
573
|
const commandAuthorized = shouldComputeCommandAuthorized
|
|
513
574
|
? rt.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
514
575
|
useAccessGroups,
|
|
515
576
|
authorizers: [
|
|
516
577
|
{
|
|
517
|
-
configured: effectiveAllowFrom.size > 0,
|
|
578
|
+
configured: isDirect ? effectiveAllowFrom.size > 0 : (groupAccess?.groupAllowFrom.length ?? 0) > 0,
|
|
518
579
|
allowed: senderAllowedForCommands,
|
|
519
580
|
},
|
|
520
581
|
],
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { ReplyPayload } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import { buildMixinReplyPlan, resolveMixinReplyPlan } from "./reply-format.js";
|
|
4
|
+
import {
|
|
5
|
+
sendAudioMessage,
|
|
6
|
+
sendButtonGroupMessage,
|
|
7
|
+
sendCardMessage,
|
|
8
|
+
sendFileMessage,
|
|
9
|
+
sendPostMessage,
|
|
10
|
+
sendTextMessage,
|
|
11
|
+
} 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
|
+
};
|
|
18
|
+
|
|
19
|
+
export type MixinOutboundStep =
|
|
20
|
+
| { kind: "text"; text: string }
|
|
21
|
+
| { kind: "post"; text: string }
|
|
22
|
+
| { kind: "file"; file: Parameters<typeof sendFileMessage>[4] }
|
|
23
|
+
| { kind: "audio"; audio: Parameters<typeof sendAudioMessage>[4] }
|
|
24
|
+
| { kind: "buttons"; intro?: string; buttons: Parameters<typeof sendButtonGroupMessage>[4] }
|
|
25
|
+
| { kind: "card"; card: Parameters<typeof sendCardMessage>[4] }
|
|
26
|
+
| { kind: "media-url"; mediaUrl: string };
|
|
27
|
+
|
|
28
|
+
export type MixinOutboundPlan = {
|
|
29
|
+
steps: MixinOutboundStep[];
|
|
30
|
+
warnings: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function appendReplyTextPlan(
|
|
34
|
+
steps: MixinOutboundStep[],
|
|
35
|
+
warnings: string[],
|
|
36
|
+
text: string,
|
|
37
|
+
options?: {
|
|
38
|
+
allowAttachmentTemplates?: boolean;
|
|
39
|
+
},
|
|
40
|
+
): void {
|
|
41
|
+
const resolution = resolveMixinReplyPlan(text);
|
|
42
|
+
if (resolution.matchedTemplate && !resolution.plan) {
|
|
43
|
+
steps.push({
|
|
44
|
+
kind: "text",
|
|
45
|
+
text: `Mixin template error: ${resolution.error ?? "invalid template"}`,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const plan = resolution.plan ?? buildMixinReplyPlan(text);
|
|
51
|
+
if (!plan) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if ((plan.kind === "file" || plan.kind === "audio") && options?.allowAttachmentTemplates === false) {
|
|
56
|
+
warnings.push(`ignored ${plan.kind} template because native media payload already contains media`);
|
|
57
|
+
steps.push({
|
|
58
|
+
kind: "text",
|
|
59
|
+
text: `Mixin template warning: ${plan.kind} template was ignored because mediaUrl/mediaUrls is already present.`,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (plan.kind === "text") {
|
|
65
|
+
steps.push({ kind: "text", text: plan.text });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (plan.kind === "post") {
|
|
69
|
+
steps.push({ kind: "post", text: plan.text });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (plan.kind === "file") {
|
|
73
|
+
steps.push({ kind: "file", file: plan.file });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (plan.kind === "audio") {
|
|
77
|
+
steps.push({ kind: "audio", audio: plan.audio });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (plan.kind === "buttons") {
|
|
81
|
+
steps.push({ kind: "buttons", intro: plan.intro, buttons: plan.buttons });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
steps.push({ kind: "card", card: plan.card });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildMixinOutboundPlanFromReplyText(text: string): MixinOutboundPlan {
|
|
88
|
+
const steps: MixinOutboundStep[] = [];
|
|
89
|
+
const warnings: string[] = [];
|
|
90
|
+
appendReplyTextPlan(steps, warnings, text, { allowAttachmentTemplates: true });
|
|
91
|
+
return { steps, warnings };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildMixinOutboundPlanFromReplyPayload(payload: ReplyPayload): MixinOutboundPlan {
|
|
95
|
+
const steps: MixinOutboundStep[] = [];
|
|
96
|
+
const warnings: string[] = [];
|
|
97
|
+
const mediaUrls = payload.mediaUrls && payload.mediaUrls.length > 0
|
|
98
|
+
? payload.mediaUrls
|
|
99
|
+
: payload.mediaUrl
|
|
100
|
+
? [payload.mediaUrl]
|
|
101
|
+
: [];
|
|
102
|
+
|
|
103
|
+
if (payload.text?.trim()) {
|
|
104
|
+
appendReplyTextPlan(steps, warnings, payload.text, {
|
|
105
|
+
allowAttachmentTemplates: mediaUrls.length === 0,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const mediaUrl of mediaUrls) {
|
|
110
|
+
steps.push({ kind: "media-url", mediaUrl });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { steps, warnings };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function executeMixinOutboundPlan(params: {
|
|
117
|
+
cfg: OpenClawConfig;
|
|
118
|
+
accountId: string;
|
|
119
|
+
conversationId: string;
|
|
120
|
+
recipientId?: string;
|
|
121
|
+
steps: MixinOutboundStep[];
|
|
122
|
+
log?: SendLog;
|
|
123
|
+
sendMediaUrl?: (mediaUrl: string) => Promise<string | undefined>;
|
|
124
|
+
}): Promise<string | undefined> {
|
|
125
|
+
const { cfg, accountId, conversationId, recipientId, steps, log, sendMediaUrl } = params;
|
|
126
|
+
let lastMessageId: string | undefined;
|
|
127
|
+
|
|
128
|
+
for (const step of steps) {
|
|
129
|
+
if (step.kind === "text") {
|
|
130
|
+
const result = await sendTextMessage(cfg, accountId, conversationId, recipientId, step.text, log);
|
|
131
|
+
if (!result.ok) {
|
|
132
|
+
throw new Error(result.error ?? "mixin outbound text send failed");
|
|
133
|
+
}
|
|
134
|
+
lastMessageId = result.messageId ?? lastMessageId;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (step.kind === "post") {
|
|
139
|
+
const result = await sendPostMessage(cfg, accountId, conversationId, recipientId, step.text, log);
|
|
140
|
+
if (!result.ok) {
|
|
141
|
+
throw new Error(result.error ?? "mixin outbound post send failed");
|
|
142
|
+
}
|
|
143
|
+
lastMessageId = result.messageId ?? lastMessageId;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (step.kind === "file") {
|
|
148
|
+
const result = await sendFileMessage(cfg, accountId, conversationId, recipientId, step.file, log);
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
throw new Error(result.error ?? "mixin outbound file send failed");
|
|
151
|
+
}
|
|
152
|
+
lastMessageId = result.messageId ?? lastMessageId;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (step.kind === "audio") {
|
|
157
|
+
const result = await sendAudioMessage(cfg, accountId, conversationId, recipientId, step.audio, log);
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
throw new Error(result.error ?? "mixin outbound audio send failed");
|
|
160
|
+
}
|
|
161
|
+
lastMessageId = result.messageId ?? lastMessageId;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (step.kind === "buttons") {
|
|
166
|
+
if (step.intro) {
|
|
167
|
+
const introResult = await sendTextMessage(cfg, accountId, conversationId, recipientId, step.intro, log);
|
|
168
|
+
if (!introResult.ok) {
|
|
169
|
+
throw new Error(introResult.error ?? "mixin outbound intro send failed");
|
|
170
|
+
}
|
|
171
|
+
lastMessageId = introResult.messageId ?? lastMessageId;
|
|
172
|
+
}
|
|
173
|
+
const result = await sendButtonGroupMessage(cfg, accountId, conversationId, recipientId, step.buttons, log);
|
|
174
|
+
if (!result.ok) {
|
|
175
|
+
throw new Error(result.error ?? "mixin outbound buttons send failed");
|
|
176
|
+
}
|
|
177
|
+
lastMessageId = result.messageId ?? lastMessageId;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (step.kind === "card") {
|
|
182
|
+
const result = await sendCardMessage(cfg, accountId, conversationId, recipientId, step.card, log);
|
|
183
|
+
if (!result.ok) {
|
|
184
|
+
throw new Error(result.error ?? "mixin outbound card send failed");
|
|
185
|
+
}
|
|
186
|
+
lastMessageId = result.messageId ?? lastMessageId;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!sendMediaUrl) {
|
|
191
|
+
throw new Error("mixin outbound mediaUrl handler not configured");
|
|
192
|
+
}
|
|
193
|
+
lastMessageId = await sendMediaUrl(step.mediaUrl) ?? lastMessageId;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return lastMessageId;
|
|
197
|
+
}
|