@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.
@@ -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
+ }
@@ -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(cfg, accountId, conversationId, recipientId, "PLAIN_DATA", body, log);
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(cfg, accountId, conversationId, recipientId, "PLAIN_AUDIO", body, log);
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
  );