@invago/mixin 1.0.10 → 1.0.12
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 +684 -661
- package/README.zh-CN.md +696 -673
- package/index.ts +104 -27
- package/openclaw.plugin.json +8 -3
- package/package.json +4 -1
- package/src/channel.ts +526 -458
- package/src/config.ts +53 -4
- package/src/inbound-handler.ts +282 -140
- package/src/message-context.ts +114 -0
- package/src/onboarding.ts +342 -0
- package/src/plugin-admin.ts +161 -0
- package/src/send-service.ts +35 -4
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
type MixinMessageDirection = "inbound" | "outbound";
|
|
2
|
+
|
|
3
|
+
export type MixinMessageContext = {
|
|
4
|
+
accountId: string;
|
|
5
|
+
conversationId: string;
|
|
6
|
+
messageId: string;
|
|
7
|
+
senderId?: string;
|
|
8
|
+
senderName?: string;
|
|
9
|
+
body: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
direction: MixinMessageDirection;
|
|
12
|
+
quoteMessageId?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ResolvedMixinReplyContext = {
|
|
16
|
+
id: string;
|
|
17
|
+
body?: string;
|
|
18
|
+
sender?: string;
|
|
19
|
+
senderId?: string;
|
|
20
|
+
timestamp?: string;
|
|
21
|
+
direction?: MixinMessageDirection;
|
|
22
|
+
found: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MAX_MESSAGE_CONTEXTS = 4000;
|
|
26
|
+
const recentMessages = new Map<string, MixinMessageContext>();
|
|
27
|
+
|
|
28
|
+
function normalizeKeyPart(value: string | null | undefined): string {
|
|
29
|
+
return value?.trim().toLowerCase() ?? "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildMessageContextKey(params: {
|
|
33
|
+
accountId: string;
|
|
34
|
+
conversationId: string;
|
|
35
|
+
messageId: string;
|
|
36
|
+
}): string {
|
|
37
|
+
return [
|
|
38
|
+
normalizeKeyPart(params.accountId),
|
|
39
|
+
normalizeKeyPart(params.conversationId),
|
|
40
|
+
normalizeKeyPart(params.messageId),
|
|
41
|
+
].join(":");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pruneRecentMessages(): void {
|
|
45
|
+
while (recentMessages.size > MAX_MESSAGE_CONTEXTS) {
|
|
46
|
+
const first = recentMessages.keys().next().value;
|
|
47
|
+
if (!first) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
recentMessages.delete(first);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function rememberMixinMessage(context: MixinMessageContext): void {
|
|
55
|
+
const accountId = context.accountId.trim();
|
|
56
|
+
const conversationId = context.conversationId.trim();
|
|
57
|
+
const messageId = context.messageId.trim();
|
|
58
|
+
if (!accountId || !conversationId || !messageId) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
recentMessages.set(
|
|
63
|
+
buildMessageContextKey({ accountId, conversationId, messageId }),
|
|
64
|
+
{
|
|
65
|
+
accountId,
|
|
66
|
+
conversationId,
|
|
67
|
+
messageId,
|
|
68
|
+
senderId: context.senderId?.trim() || undefined,
|
|
69
|
+
senderName: context.senderName?.trim() || undefined,
|
|
70
|
+
body: context.body ?? "",
|
|
71
|
+
timestamp: context.timestamp,
|
|
72
|
+
direction: context.direction,
|
|
73
|
+
quoteMessageId: context.quoteMessageId?.trim() || undefined,
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
pruneRecentMessages();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resolveMixinReplyContext(params: {
|
|
81
|
+
accountId: string;
|
|
82
|
+
conversationId: string;
|
|
83
|
+
quoteMessageId?: string | null;
|
|
84
|
+
}): ResolvedMixinReplyContext | null {
|
|
85
|
+
const quoteMessageId = params.quoteMessageId?.trim();
|
|
86
|
+
if (!quoteMessageId) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const message = recentMessages.get(
|
|
91
|
+
buildMessageContextKey({
|
|
92
|
+
accountId: params.accountId,
|
|
93
|
+
conversationId: params.conversationId,
|
|
94
|
+
messageId: quoteMessageId,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (!message) {
|
|
99
|
+
return {
|
|
100
|
+
id: quoteMessageId,
|
|
101
|
+
found: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id: message.messageId,
|
|
107
|
+
body: message.body || undefined,
|
|
108
|
+
sender: message.senderName || message.senderId,
|
|
109
|
+
senderId: message.senderId,
|
|
110
|
+
timestamp: message.timestamp,
|
|
111
|
+
direction: message.direction,
|
|
112
|
+
found: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelOnboardingAdapter,
|
|
3
|
+
ChannelOnboardingDmPolicy,
|
|
4
|
+
DmPolicy,
|
|
5
|
+
OpenClawConfig,
|
|
6
|
+
WizardPrompter,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
8
|
+
import { DEFAULT_ACCOUNT_ID, promptAccountId } from "openclaw/plugin-sdk";
|
|
9
|
+
import { getAccountConfig, listAccountIds, resolveAccount, resolveDefaultAccountId } from "./config.js";
|
|
10
|
+
import type { MixinAccountConfig } from "./config-schema.js";
|
|
11
|
+
|
|
12
|
+
const channel = "mixin" as const;
|
|
13
|
+
|
|
14
|
+
type MixinConfigRoot = Partial<MixinAccountConfig> & {
|
|
15
|
+
defaultAccount?: string;
|
|
16
|
+
accounts?: Record<string, Partial<MixinAccountConfig> | undefined>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type MixinGroupPolicy = NonNullable<MixinAccountConfig["groupPolicy"]>;
|
|
20
|
+
|
|
21
|
+
type MixinDmPolicy = NonNullable<MixinAccountConfig["dmPolicy"]>;
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getMixinRoot(cfg: OpenClawConfig): MixinConfigRoot {
|
|
28
|
+
const root = cfg as unknown as Record<string, unknown>;
|
|
29
|
+
const channels = isRecord(root.channels) ? root.channels : undefined;
|
|
30
|
+
const channelConfig = channels && isRecord(channels.mixin) ? channels.mixin : undefined;
|
|
31
|
+
if (channelConfig) {
|
|
32
|
+
return channelConfig as MixinConfigRoot;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const legacyNamedConfig = isRecord(root.mixin) ? root.mixin : undefined;
|
|
36
|
+
if (legacyNamedConfig) {
|
|
37
|
+
return legacyNamedConfig as MixinConfigRoot;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const plugins = isRecord(root.plugins) ? root.plugins : undefined;
|
|
41
|
+
const entries = plugins && isRecord(plugins.entries) ? plugins.entries : undefined;
|
|
42
|
+
const mixinEntry = entries && isRecord(entries.mixin) ? entries.mixin : undefined;
|
|
43
|
+
const pluginEntryConfig = mixinEntry && isRecord(mixinEntry.config) ? mixinEntry.config : undefined;
|
|
44
|
+
if (pluginEntryConfig) {
|
|
45
|
+
return pluginEntryConfig as MixinConfigRoot;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return isRecord(root) ? (root as MixinConfigRoot) : {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function updateMixinRoot(cfg: OpenClawConfig, patch: Partial<MixinConfigRoot>): OpenClawConfig {
|
|
52
|
+
const current = getMixinRoot(cfg);
|
|
53
|
+
return {
|
|
54
|
+
...cfg,
|
|
55
|
+
channels: {
|
|
56
|
+
...(cfg.channels ?? {}),
|
|
57
|
+
mixin: {
|
|
58
|
+
...current,
|
|
59
|
+
...patch,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
} as OpenClawConfig;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function updateMixinAccountConfig(
|
|
66
|
+
cfg: OpenClawConfig,
|
|
67
|
+
accountId: string,
|
|
68
|
+
patch: Partial<MixinAccountConfig>,
|
|
69
|
+
): OpenClawConfig {
|
|
70
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
71
|
+
return updateMixinRoot(cfg, patch);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const current = getMixinRoot(cfg);
|
|
75
|
+
const accounts = current.accounts ?? {};
|
|
76
|
+
|
|
77
|
+
return updateMixinRoot(cfg, {
|
|
78
|
+
accounts: {
|
|
79
|
+
...accounts,
|
|
80
|
+
[accountId]: {
|
|
81
|
+
...(accounts[accountId] ?? {}),
|
|
82
|
+
...patch,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mergeAllowFrom(values: string[] | undefined, nextValue: string): string[] {
|
|
89
|
+
const parts = nextValue
|
|
90
|
+
.split(/[\n,;]+/g)
|
|
91
|
+
.map((entry) => entry.trim())
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
return [...new Set([...(values ?? []), ...parts])];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async function promptAccountGuide(prompter: WizardPrompter): Promise<void> {
|
|
98
|
+
await prompter.note(
|
|
99
|
+
[
|
|
100
|
+
"Mixin uses Blaze WebSocket and needs one account block per bot account.",
|
|
101
|
+
"Required fields: appId, sessionId, serverPublicKey, sessionPrivateKey.",
|
|
102
|
+
"Single-account setup lives directly under channels.mixin.",
|
|
103
|
+
"Multi-account setup lives under channels.mixin.accounts.<accountId>.",
|
|
104
|
+
"Optional fields: dmPolicy, allowFrom, groupPolicy, mixpay, proxy.",
|
|
105
|
+
"Docs: README.md / README.zh-CN.md",
|
|
106
|
+
].join("\n"),
|
|
107
|
+
"Mixin setup guide",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function setRootDmPolicy(cfg: OpenClawConfig, policy: DmPolicy): OpenClawConfig {
|
|
112
|
+
return updateMixinRoot(cfg, {
|
|
113
|
+
dmPolicy: policy as MixinDmPolicy,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeDmPolicy(value: unknown): MixinDmPolicy {
|
|
118
|
+
return value === "allowlist" || value === "open" || value === "disabled" ? value : "pairing";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeGroupPolicy(value: unknown): MixinGroupPolicy {
|
|
122
|
+
return value === "allowlist" || value === "open" || value === "disabled" ? value : "open";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function defaultMixpayConfig(existing?: MixinAccountConfig["mixpay"]): NonNullable<MixinAccountConfig["mixpay"]> {
|
|
126
|
+
return {
|
|
127
|
+
enabled: existing?.enabled ?? false,
|
|
128
|
+
apiBaseUrl: existing?.apiBaseUrl,
|
|
129
|
+
payeeId: existing?.payeeId,
|
|
130
|
+
defaultQuoteAssetId: existing?.defaultQuoteAssetId,
|
|
131
|
+
defaultSettlementAssetId: existing?.defaultSettlementAssetId,
|
|
132
|
+
expireMinutes: existing?.expireMinutes ?? 15,
|
|
133
|
+
pollIntervalSec: existing?.pollIntervalSec ?? 30,
|
|
134
|
+
allowedCreators: existing?.allowedCreators ?? [],
|
|
135
|
+
notifyOnPending: existing?.notifyOnPending ?? false,
|
|
136
|
+
notifyOnPaidLess: existing?.notifyOnPaidLess ?? true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function promptAllowFrom(params: {
|
|
141
|
+
cfg: OpenClawConfig;
|
|
142
|
+
prompter: WizardPrompter;
|
|
143
|
+
accountId?: string;
|
|
144
|
+
}): Promise<OpenClawConfig> {
|
|
145
|
+
const accountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
146
|
+
const current = getAccountConfig(params.cfg, accountId).allowFrom ?? [];
|
|
147
|
+
await params.prompter.note(
|
|
148
|
+
[
|
|
149
|
+
"Enter Mixin UUID values separated by commas or new lines.",
|
|
150
|
+
"Example: 12345678-1234-1234-1234-123456789abc",
|
|
151
|
+
"Leave blank if you want to keep pairing-only access.",
|
|
152
|
+
].join("\n"),
|
|
153
|
+
"Mixin allowFrom",
|
|
154
|
+
);
|
|
155
|
+
const raw = await params.prompter.text({
|
|
156
|
+
message: "Allowed Mixin UUIDs",
|
|
157
|
+
placeholder: "uuid-one, uuid-two",
|
|
158
|
+
initialValue: current.join(", ") || undefined,
|
|
159
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
160
|
+
});
|
|
161
|
+
const allowFrom = mergeAllowFrom(current, String(raw));
|
|
162
|
+
return updateMixinAccountConfig(params.cfg, accountId, { allowFrom });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function promptAccountConfig(params: {
|
|
166
|
+
cfg: OpenClawConfig;
|
|
167
|
+
prompter: WizardPrompter;
|
|
168
|
+
accountId: string;
|
|
169
|
+
}): Promise<OpenClawConfig> {
|
|
170
|
+
const resolved = resolveAccount(params.cfg, params.accountId);
|
|
171
|
+
const current = getAccountConfig(params.cfg, params.accountId);
|
|
172
|
+
let next = params.cfg;
|
|
173
|
+
|
|
174
|
+
const appId = String(
|
|
175
|
+
await params.prompter.text({
|
|
176
|
+
message: "Mixin appId",
|
|
177
|
+
initialValue: resolved.appId ?? undefined,
|
|
178
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
179
|
+
}),
|
|
180
|
+
).trim();
|
|
181
|
+
|
|
182
|
+
const sessionId = String(
|
|
183
|
+
await params.prompter.text({
|
|
184
|
+
message: "Mixin sessionId",
|
|
185
|
+
initialValue: resolved.sessionId ?? undefined,
|
|
186
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
187
|
+
}),
|
|
188
|
+
).trim();
|
|
189
|
+
|
|
190
|
+
const serverPublicKey = String(
|
|
191
|
+
await params.prompter.text({
|
|
192
|
+
message: "Mixin serverPublicKey",
|
|
193
|
+
initialValue: resolved.serverPublicKey ?? undefined,
|
|
194
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
195
|
+
}),
|
|
196
|
+
).trim();
|
|
197
|
+
|
|
198
|
+
const sessionPrivateKey = String(
|
|
199
|
+
await params.prompter.text({
|
|
200
|
+
message: "Mixin sessionPrivateKey",
|
|
201
|
+
initialValue: resolved.sessionPrivateKey ?? undefined,
|
|
202
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
203
|
+
}),
|
|
204
|
+
).trim();
|
|
205
|
+
|
|
206
|
+
next = updateMixinAccountConfig(next, params.accountId, {
|
|
207
|
+
enabled: true,
|
|
208
|
+
appId,
|
|
209
|
+
sessionId,
|
|
210
|
+
serverPublicKey,
|
|
211
|
+
sessionPrivateKey,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const dmPolicy = await params.prompter.select<MixinDmPolicy>({
|
|
215
|
+
message: "DM policy",
|
|
216
|
+
initialValue: normalizeDmPolicy(current.dmPolicy),
|
|
217
|
+
options: [
|
|
218
|
+
{ value: "pairing", label: "Pairing", hint: "Accept DMs after pairing approval" },
|
|
219
|
+
{ value: "allowlist", label: "Allowlist", hint: "Only accept DMs from allowFrom" },
|
|
220
|
+
{ value: "open", label: "Open", hint: "Accept DMs without an allowlist" },
|
|
221
|
+
{ value: "disabled", label: "Disabled", hint: "Disable DMs for this account" },
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
next = setRootDmPolicy(next, dmPolicy);
|
|
225
|
+
next = updateMixinAccountConfig(next, params.accountId, {
|
|
226
|
+
dmPolicy,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (dmPolicy === "allowlist") {
|
|
230
|
+
next = await promptAllowFrom({ cfg: next, prompter: params.prompter, accountId: params.accountId });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const groupPolicy = await params.prompter.select<MixinGroupPolicy>({
|
|
234
|
+
message: "Group policy",
|
|
235
|
+
initialValue: normalizeGroupPolicy(current.groupPolicy),
|
|
236
|
+
options: [
|
|
237
|
+
{ value: "open", label: "Open", hint: "Allow all configured group chats" },
|
|
238
|
+
{ value: "allowlist", label: "Allowlist", hint: "Only accept listed group chats" },
|
|
239
|
+
{ value: "disabled", label: "Disabled", hint: "Disable group access" },
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
next = updateMixinAccountConfig(next, params.accountId, {
|
|
243
|
+
groupPolicy,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const addGroupAllowFrom = groupPolicy === "allowlist"
|
|
247
|
+
? await params.prompter.confirm({
|
|
248
|
+
message: "Add initial group allowFrom entries now?",
|
|
249
|
+
initialValue: true,
|
|
250
|
+
})
|
|
251
|
+
: false;
|
|
252
|
+
if (addGroupAllowFrom) {
|
|
253
|
+
const groupAllowFrom = String(
|
|
254
|
+
await params.prompter.text({
|
|
255
|
+
message: "Group allowFrom",
|
|
256
|
+
placeholder: "conversation-id-one, conversation-id-two",
|
|
257
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
258
|
+
}),
|
|
259
|
+
)
|
|
260
|
+
.split(/[\n,;]+/g)
|
|
261
|
+
.map((entry) => entry.trim())
|
|
262
|
+
.filter(Boolean);
|
|
263
|
+
next = updateMixinAccountConfig(next, params.accountId, {
|
|
264
|
+
groupAllowFrom,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const mixpayEnabled = await params.prompter.confirm({
|
|
269
|
+
message: "Enable MixPay for this account?",
|
|
270
|
+
initialValue: Boolean(current.mixpay?.enabled),
|
|
271
|
+
});
|
|
272
|
+
if (mixpayEnabled) {
|
|
273
|
+
next = updateMixinAccountConfig(next, params.accountId, {
|
|
274
|
+
mixpay: {
|
|
275
|
+
...defaultMixpayConfig(current.mixpay),
|
|
276
|
+
enabled: true,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return next;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const dmPolicy: ChannelOnboardingDmPolicy = {
|
|
285
|
+
label: "Mixin",
|
|
286
|
+
channel,
|
|
287
|
+
policyKey: "channels.mixin.dmPolicy",
|
|
288
|
+
allowFromKey: "channels.mixin.allowFrom",
|
|
289
|
+
getCurrent: (cfg) => normalizeDmPolicy((cfg.channels?.mixin as { dmPolicy?: unknown } | undefined)?.dmPolicy),
|
|
290
|
+
setPolicy: (cfg, policy) => setRootDmPolicy(cfg, policy as DmPolicy),
|
|
291
|
+
promptAllowFrom,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
export const mixinOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
295
|
+
channel,
|
|
296
|
+
getStatus: async ({ cfg }) => {
|
|
297
|
+
const accountIds = listAccountIds(cfg);
|
|
298
|
+
const configured = accountIds.some((accountId) => resolveAccount(cfg, accountId).configured);
|
|
299
|
+
return {
|
|
300
|
+
channel,
|
|
301
|
+
configured,
|
|
302
|
+
statusLines: [
|
|
303
|
+
`Mixin: ${configured ? "configured" : "needs credentials"}`,
|
|
304
|
+
`Accounts: ${accountIds.length}`,
|
|
305
|
+
],
|
|
306
|
+
selectionHint: configured ? "configured" : "Blaze WebSocket Mixin bridge",
|
|
307
|
+
quickstartScore: configured ? 1 : 4,
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
|
311
|
+
await promptAccountGuide(prompter);
|
|
312
|
+
|
|
313
|
+
const mixinOverride = accountOverrides.mixin?.trim();
|
|
314
|
+
const defaultAccountId = resolveDefaultAccountId(cfg);
|
|
315
|
+
let accountId = mixinOverride || defaultAccountId;
|
|
316
|
+
if (shouldPromptAccountIds && !mixinOverride) {
|
|
317
|
+
accountId = await promptAccountId({
|
|
318
|
+
cfg,
|
|
319
|
+
prompter,
|
|
320
|
+
label: "Mixin",
|
|
321
|
+
currentId: accountId,
|
|
322
|
+
listAccountIds,
|
|
323
|
+
defaultAccountId,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const next = await promptAccountConfig({ cfg, prompter, accountId });
|
|
328
|
+
await prompter.outro(
|
|
329
|
+
[
|
|
330
|
+
`Configured account: ${accountId}`,
|
|
331
|
+
"Restart the Gateway after saving the config.",
|
|
332
|
+
"Use /mixin-status to verify the connection.",
|
|
333
|
+
].join("\n"),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
cfg: next,
|
|
338
|
+
accountId,
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
dmPolicy,
|
|
342
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { describeAccount, getAccountConfig, listAccountIds, resolveAccount, resolveDefaultAccountId } from "./config.js";
|
|
3
|
+
import { getMixpayStatusSnapshot } from "./mixpay-worker.js";
|
|
4
|
+
import { getOutboxStatus } from "./send-service.js";
|
|
5
|
+
import { resolveMixinStatusSnapshot } from "./status.js";
|
|
6
|
+
|
|
7
|
+
export type MixinPluginDiagnostics = {
|
|
8
|
+
defaultAccountId: string;
|
|
9
|
+
accountIds: string[];
|
|
10
|
+
accounts: ReturnType<typeof describeAccount>[];
|
|
11
|
+
outboxPending: number;
|
|
12
|
+
mixpayPendingOrders: number;
|
|
13
|
+
outboxDir: string;
|
|
14
|
+
outboxFile: string;
|
|
15
|
+
mixpayStoreDir: string | null;
|
|
16
|
+
mixpayStoreFile: string | null;
|
|
17
|
+
mediaMaxMb: number | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MixinSetupMode = "summary" | "single" | "multi";
|
|
21
|
+
|
|
22
|
+
function formatLine(label: string, value: string | number | boolean | null | undefined): string {
|
|
23
|
+
return `${label}: ${value ?? "-"}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatAccountSummary(cfg: OpenClawConfig, accountId: string): string {
|
|
27
|
+
const account = resolveAccount(cfg, accountId);
|
|
28
|
+
const accountConfig = getAccountConfig(cfg, accountId);
|
|
29
|
+
const status = account.enabled ? (account.configured ? "ready" : "missing-credentials") : "disabled";
|
|
30
|
+
const mixpay = accountConfig.mixpay?.enabled ? "mixpay-on" : "mixpay-off";
|
|
31
|
+
return [
|
|
32
|
+
`${account.accountId} (${account.name ?? account.accountId})`,
|
|
33
|
+
status,
|
|
34
|
+
mixpay,
|
|
35
|
+
].join(" | ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function buildMixinPluginDiagnostics(cfg: OpenClawConfig): Promise<MixinPluginDiagnostics> {
|
|
39
|
+
const defaultAccountId = resolveDefaultAccountId(cfg);
|
|
40
|
+
const accountIds = listAccountIds(cfg);
|
|
41
|
+
const accounts = accountIds.map((accountId) => describeAccount(resolveAccount(cfg, accountId)));
|
|
42
|
+
const outboxStatus = await getOutboxStatus().catch(() => null);
|
|
43
|
+
const mixpayStatus = await getMixpayStatusSnapshot().catch(() => null);
|
|
44
|
+
const snapshot = resolveMixinStatusSnapshot(cfg, defaultAccountId, outboxStatus, mixpayStatus);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
defaultAccountId,
|
|
48
|
+
accountIds,
|
|
49
|
+
accounts,
|
|
50
|
+
outboxPending: snapshot.outboxPending,
|
|
51
|
+
mixpayPendingOrders: snapshot.mixpayPendingOrders,
|
|
52
|
+
outboxDir: snapshot.outboxDir,
|
|
53
|
+
outboxFile: snapshot.outboxFile,
|
|
54
|
+
mixpayStoreDir: snapshot.mixpayStoreDir,
|
|
55
|
+
mixpayStoreFile: snapshot.mixpayStoreFile,
|
|
56
|
+
mediaMaxMb: snapshot.mediaMaxMb,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatMixinStatusText(cfg: OpenClawConfig, diagnostics: MixinPluginDiagnostics): string {
|
|
61
|
+
const lines = [
|
|
62
|
+
"Mixin plugin status",
|
|
63
|
+
formatLine("defaultAccountId", diagnostics.defaultAccountId),
|
|
64
|
+
formatLine("accounts", diagnostics.accountIds.length),
|
|
65
|
+
formatLine("outboxPending", diagnostics.outboxPending),
|
|
66
|
+
formatLine("mixpayPendingOrders", diagnostics.mixpayPendingOrders),
|
|
67
|
+
formatLine("outboxDir", diagnostics.outboxDir),
|
|
68
|
+
formatLine("outboxFile", diagnostics.outboxFile),
|
|
69
|
+
formatLine("mixpayStoreDir", diagnostics.mixpayStoreDir),
|
|
70
|
+
formatLine("mixpayStoreFile", diagnostics.mixpayStoreFile),
|
|
71
|
+
formatLine("mediaMaxMb", diagnostics.mediaMaxMb),
|
|
72
|
+
"",
|
|
73
|
+
"Accounts",
|
|
74
|
+
...diagnostics.accountIds.map((accountId) => `- ${formatAccountSummary(cfg, accountId)}`),
|
|
75
|
+
];
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatMixinHelpText(): string {
|
|
80
|
+
return [
|
|
81
|
+
"Mixin plugin commands",
|
|
82
|
+
"/setup [summary|single|multi] - open the setup guide",
|
|
83
|
+
"/mixin-setup [summary|single|multi] - open the setup guide",
|
|
84
|
+
"/mixin-status - show account and queue status",
|
|
85
|
+
"/mixin-accounts - list configured accounts",
|
|
86
|
+
"/mixin-help - show this help text",
|
|
87
|
+
"",
|
|
88
|
+
"Configuration",
|
|
89
|
+
"channels.mixin for the default account",
|
|
90
|
+
"channels.mixin.accounts.<accountId> for multi-account setups",
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildMixinAccountsText(cfg: OpenClawConfig): string {
|
|
95
|
+
const accountIds = listAccountIds(cfg);
|
|
96
|
+
const lines = ["Configured accounts", ...accountIds.map((accountId) => `- ${formatAccountSummary(cfg, accountId)}`)];
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function normalizeMixinSetupMode(input: string | undefined): MixinSetupMode {
|
|
101
|
+
const mode = input?.trim().toLowerCase() ?? "";
|
|
102
|
+
if (mode === "single" || mode === "multi" || mode === "summary") {
|
|
103
|
+
return mode;
|
|
104
|
+
}
|
|
105
|
+
return "summary";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function formatMixinSetupText(
|
|
109
|
+
cfg: OpenClawConfig,
|
|
110
|
+
diagnostics?: MixinPluginDiagnostics,
|
|
111
|
+
mode: MixinSetupMode = "summary",
|
|
112
|
+
): string {
|
|
113
|
+
const resolved = diagnostics ?? {
|
|
114
|
+
defaultAccountId: resolveDefaultAccountId(cfg),
|
|
115
|
+
accountIds: listAccountIds(cfg),
|
|
116
|
+
accounts: listAccountIds(cfg).map((accountId) => describeAccount(resolveAccount(cfg, accountId))),
|
|
117
|
+
outboxPending: 0,
|
|
118
|
+
mixpayPendingOrders: 0,
|
|
119
|
+
outboxDir: "-",
|
|
120
|
+
outboxFile: "-",
|
|
121
|
+
mixpayStoreDir: null,
|
|
122
|
+
mixpayStoreFile: null,
|
|
123
|
+
mediaMaxMb: null,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return [
|
|
127
|
+
"Mixin setup",
|
|
128
|
+
"",
|
|
129
|
+
mode === "single"
|
|
130
|
+
? "Single-account flow"
|
|
131
|
+
: mode === "multi"
|
|
132
|
+
? "Multi-account flow"
|
|
133
|
+
: "Quick summary",
|
|
134
|
+
"",
|
|
135
|
+
mode === "single"
|
|
136
|
+
? "1. Put the account fields directly under channels.mixin."
|
|
137
|
+
: "1. Keep or create the default account under channels.mixin.",
|
|
138
|
+
mode === "single"
|
|
139
|
+
? "2. Fill in appId, sessionId, serverPublicKey, and sessionPrivateKey."
|
|
140
|
+
: "2. For multi-account setups, use channels.mixin.accounts.<accountId>.",
|
|
141
|
+
mode === "single"
|
|
142
|
+
? "3. Use /mixin-status after restart to confirm the account is ready."
|
|
143
|
+
: "3. Fill in appId, sessionId, serverPublicKey, and sessionPrivateKey.",
|
|
144
|
+
mode === "single"
|
|
145
|
+
? "4. Use /mixin-help to see the available commands."
|
|
146
|
+
: "4. Use /mixin-status after restart to confirm the account is ready.",
|
|
147
|
+
mode === "multi"
|
|
148
|
+
? "5. Use /mixin-accounts to verify all configured accounts."
|
|
149
|
+
: "5. Use /mixin-accounts to verify all configured accounts.",
|
|
150
|
+
"",
|
|
151
|
+
`Default account: ${resolved.defaultAccountId}`,
|
|
152
|
+
`Accounts: ${resolved.accountIds.length}`,
|
|
153
|
+
...resolved.accountIds.map((accountId) => `- ${formatAccountSummary(cfg, accountId)}`),
|
|
154
|
+
"",
|
|
155
|
+
"Optional fields you can adjust later:",
|
|
156
|
+
"- dmPolicy / allowFrom",
|
|
157
|
+
"- groupPolicy / groupAllowFrom",
|
|
158
|
+
"- mixpay",
|
|
159
|
+
"- proxy",
|
|
160
|
+
].join("\n");
|
|
161
|
+
}
|
package/src/send-service.ts
CHANGED
|
@@ -4,6 +4,7 @@ import os from "os";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
6
6
|
import { getAccountConfig } from "./config.js";
|
|
7
|
+
import { rememberMixinMessage } from "./message-context.js";
|
|
7
8
|
import { getMixinBlazeSender, getMixinRuntime } from "./runtime.js";
|
|
8
9
|
import { buildClient, sleep, type SendLog } from "./shared.js";
|
|
9
10
|
|
|
@@ -623,7 +624,7 @@ export async function sendTextMessage(
|
|
|
623
624
|
text: string,
|
|
624
625
|
log?: SendLog,
|
|
625
626
|
): Promise<SendResult> {
|
|
626
|
-
return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_TEXT", text, log);
|
|
627
|
+
return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_TEXT", text, log, text);
|
|
627
628
|
}
|
|
628
629
|
|
|
629
630
|
export async function sendPostMessage(
|
|
@@ -634,7 +635,7 @@ export async function sendPostMessage(
|
|
|
634
635
|
text: string,
|
|
635
636
|
log?: SendLog,
|
|
636
637
|
): Promise<SendResult> {
|
|
637
|
-
return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_POST", text, log);
|
|
638
|
+
return sendMixinMessage(cfg, accountId, conversationId, recipientId, "PLAIN_POST", text, log, text);
|
|
638
639
|
}
|
|
639
640
|
|
|
640
641
|
export async function sendFileMessage(
|
|
@@ -654,7 +655,16 @@ export async function sendFileMessage(
|
|
|
654
655
|
mimeType,
|
|
655
656
|
} satisfies FileOutboxBody);
|
|
656
657
|
|
|
657
|
-
return sendMixinMessage(
|
|
658
|
+
return sendMixinMessage(
|
|
659
|
+
cfg,
|
|
660
|
+
accountId,
|
|
661
|
+
conversationId,
|
|
662
|
+
recipientId,
|
|
663
|
+
"PLAIN_DATA",
|
|
664
|
+
body,
|
|
665
|
+
log,
|
|
666
|
+
`${fileName} (${mimeType})`,
|
|
667
|
+
);
|
|
658
668
|
}
|
|
659
669
|
|
|
660
670
|
export async function sendAudioMessage(
|
|
@@ -674,7 +684,16 @@ export async function sendAudioMessage(
|
|
|
674
684
|
waveForm: audio.waveForm,
|
|
675
685
|
} satisfies AudioOutboxBody);
|
|
676
686
|
|
|
677
|
-
return sendMixinMessage(
|
|
687
|
+
return sendMixinMessage(
|
|
688
|
+
cfg,
|
|
689
|
+
accountId,
|
|
690
|
+
conversationId,
|
|
691
|
+
recipientId,
|
|
692
|
+
"PLAIN_AUDIO",
|
|
693
|
+
body,
|
|
694
|
+
log,
|
|
695
|
+
`${path.basename(audio.filePath)} (${mimeType}${audio.duration ? `, ${audio.duration}s` : ""})`,
|
|
696
|
+
);
|
|
678
697
|
}
|
|
679
698
|
|
|
680
699
|
export async function sendButtonGroupMessage(
|
|
@@ -718,6 +737,7 @@ async function sendMixinMessage(
|
|
|
718
737
|
category: MixinSupportedMessageCategory,
|
|
719
738
|
body: string,
|
|
720
739
|
log?: SendLog,
|
|
740
|
+
contextBody?: string,
|
|
721
741
|
): Promise<SendResult> {
|
|
722
742
|
updateRuntime(cfg, log);
|
|
723
743
|
await startSendWorker(cfg, log);
|
|
@@ -742,6 +762,17 @@ async function sendMixinMessage(
|
|
|
742
762
|
await persistEntries();
|
|
743
763
|
wakeWorker();
|
|
744
764
|
|
|
765
|
+
rememberMixinMessage({
|
|
766
|
+
accountId,
|
|
767
|
+
conversationId,
|
|
768
|
+
messageId: entry.messageId,
|
|
769
|
+
senderId: accountId,
|
|
770
|
+
senderName: "Mixin bot",
|
|
771
|
+
body: contextBody ?? body,
|
|
772
|
+
timestamp: now,
|
|
773
|
+
direction: "outbound",
|
|
774
|
+
});
|
|
775
|
+
|
|
745
776
|
state.log.info(
|
|
746
777
|
`[mixin] outbox enqueued: jobId=${entry.jobId}, messageId=${entry.messageId}, category=${category}, accountId=${accountId}, conversation=${conversationId}`,
|
|
747
778
|
);
|