@invago/mixin 1.0.11 → 1.0.13

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 CHANGED
@@ -38,14 +38,22 @@ To install a specific version for the first time:
38
38
  openclaw plugins install @invago/mixin@<version>
39
39
  ```
40
40
 
41
- Then confirm the plugin is installed:
42
-
43
- ```bash
44
- openclaw plugins list
45
- openclaw plugins info mixin
46
- ```
47
-
48
- ## Local Development Install
41
+ Then confirm the plugin is installed:
42
+
43
+ ```bash
44
+ openclaw plugins list
45
+ openclaw plugins info mixin
46
+ ```
47
+
48
+ ## Cross-Platform Checklist
49
+
50
+ - The same install commands work on Windows, Linux, and macOS.
51
+ - Make sure `openclaw`, `node`, and your package manager (`npm` or `pnpm`) are available on `PATH`.
52
+ - Voice duration detection needs `ffprobe`. If it is missing, audio falls back to file sending unless `audioRequireFfprobe` is enabled.
53
+ - For local development, run `npm install` once and then `openclaw plugins install -l .` or `openclaw plugins install .`.
54
+ - Runtime data is stored under the OpenClaw state directory resolved from `OPENCLAW_STATE_DIR`, `CLAWDBOT_STATE_DIR`, or `OPENCLAW_HOME`; no OS-specific path is required in plugin config.
55
+
56
+ ## Local Development Install
49
57
 
50
58
  If you are developing locally, clone the repository and install dependencies:
51
59
 
@@ -315,12 +323,14 @@ Mixin now supports formal group access controls in addition to direct-message `d
315
323
  - `groupPolicy: "disabled"` blocks the entire conversation.
316
324
  - `conversations.<conversationId>` overrides account-level group settings for that single conversation.
317
325
 
318
- Important delivery boundary:
319
-
320
- - In practice, Mixin group bots reliably receive messages when the bot is explicitly mentioned.
321
- - `requireMentionInGroup: false` only disables this plugin's own post-delivery filtering.
322
- - It does not guarantee that Mixin will deliver every non-mention group message to the bot.
323
- - If a non-mention group message produces no read receipt and no inbound log, the message most likely was not delivered to the plugin by Mixin in the first place.
326
+ Important delivery boundary:
327
+
328
+ - In practice, Mixin group bots reliably receive messages when the bot is explicitly mentioned.
329
+ - The most reliable format is `@<identity_number> your message`, for example `@7000103034 hello`.
330
+ - `requireMentionInGroup: false` only disables this plugin's own post-delivery filtering.
331
+ - It does not guarantee that Mixin will deliver every non-mention group message to the bot.
332
+ - If a non-mention group message produces no read receipt and no inbound log, the message most likely was not delivered to the plugin by Mixin in the first place.
333
+ - Group quote/reply interactions are currently not treated as a reliable bot trigger, because Mixin may not deliver those events to the bot over Blaze consistently.
324
334
 
325
335
  Example:
326
336
 
@@ -373,10 +383,12 @@ Where to look in logs:
373
383
  - Unauthorized or filtered group logs include `group sender <user_id>` and `conversationId=<conversationId>`
374
384
  - If needed, temporarily enable a stricter group policy and let one member send a message once; the rejection log is often the fastest way to collect both values
375
385
 
376
- ## Usage
377
-
378
- - Direct message: `/status` or `Hello`
379
- - Group message: `@Bot your question` with trigger words such as `?` or `help`
386
+ ## Usage
387
+
388
+ - Direct message: `/status` or `Hello`
389
+ - Group message: `@<identity_number> your question`
390
+ - Recommended example: `@7000103034 help me summarize this`
391
+ - Do not rely on quote-only or quote-plus-mention group replies as a stable trigger path.
380
392
 
381
393
  ## Operations
382
394
 
package/README.zh-CN.md CHANGED
@@ -315,12 +315,14 @@ OpenClaw 支持通过 `bindings[].match.accountId` 把不同的频道账号直
315
315
  - `groupPolicy: "disabled"` 表示整个群会话被禁用。
316
316
  - `conversations.<conversationId>` 可以对某一个群会话覆盖账号级配置。
317
317
 
318
- 关于群消息投递边界:
319
-
320
- - 目前从实际联调看,Mixin 群里稳定可用的触发方式仍然是显式 `@bot`。
321
- - `requireMentionInGroup: false` 只表示关闭插件自身的群消息二次过滤。
322
- - 它不能保证 Mixin 平台一定把所有未 `@` 的群消息投递给机器人。
323
- - 如果群里未 `@` 的消息既没有已读,也没有任何入站日志,通常说明这条消息根本没有被 Mixin 投递到插件。
318
+ 关于群消息投递边界:
319
+
320
+ - 目前从实际联调看,Mixin 群里稳定可用的触发方式仍然是显式 `@bot`。
321
+ - 最稳的写法是 `@<identity_number> + 文本`,例如 `@7000103034 你好`。
322
+ - `requireMentionInGroup: false` 只表示关闭插件自身的群消息二次过滤。
323
+ - 它不能保证 Mixin 平台一定把所有未 `@` 的群消息投递给机器人。
324
+ - 如果群里未 `@` 的消息既没有已读,也没有任何入站日志,通常说明这条消息根本没有被 Mixin 投递到插件。
325
+ - 目前群内“引用回复”不应被当成稳定触发方式,因为 Mixin 不一定会把这类事件稳定投递给 bot。
324
326
 
325
327
  示例:
326
328
 
@@ -376,10 +378,12 @@ OpenClaw 支持通过 `bindings[].match.accountId` 把不同的频道账号直
376
378
  - 群消息被拦截或未授权时,日志里会带 `group sender <user_id>` 和 `conversationId=<conversationId>`
377
379
  - 如果现场不好拿值,可以临时把群策略收紧一点,让目标成员先发一条消息;拒绝日志通常是最快同时拿到这两个值的方式
378
380
 
379
- ## 使用方式
380
-
381
- - 私聊:`/status` 或 `Hello`
382
- - 群聊:`@Bot your question`,并带上 `?` 或 `help` 等触发词
381
+ ## 使用方式
382
+
383
+ - 私聊:`/status` 或 `Hello`
384
+ - 群聊:`@<identity_number> 你的问题`
385
+ - 推荐示例:`@7000103034 帮我总结一下`
386
+ - 目前不要把“仅引用回复”或“引用后再 @”当作稳定触发方式。
383
387
 
384
388
  ## 运维
385
389
 
@@ -696,3 +700,11 @@ Mixin 现在已经支持通过 MixPay one-time payment 做收款。
696
700
  - 本地开发建议使用 `openclaw plugins install -l .`。
697
701
 
698
702
  - 使用 `/setup` 进入配置引导流程。
703
+
704
+ ## 跨平台检查清单
705
+
706
+ - Windows、Linux、macOS 都使用同一套安装命令。
707
+ - 请确保 `openclaw`、`node` 和包管理器(`npm` 或 `pnpm`)已经加入 `PATH`。
708
+ - 语音时长识别依赖 `ffprobe`。如果系统里没有它,音频会降级为按文件发送,除非你显式开启 `audioRequireFfprobe`。
709
+ - 本地开发时,先执行一次 `npm install`,然后用 `openclaw plugins install -l .` 或 `openclaw plugins install .` 安装。
710
+ - 运行时数据会存放在 OpenClaw 状态目录中,来源可能是 `OPENCLAW_STATE_DIR`、`CLAWDBOT_STATE_DIR` 或 `OPENCLAW_HOME`,插件配置里不需要写死操作系统路径。
@@ -2,7 +2,7 @@
2
2
  "id": "mixin",
3
3
  "name": "Mixin Messenger Channel",
4
4
  "description": "Mixin Messenger channel via Blaze WebSocket",
5
- "version": "1.0.10",
5
+ "version": "1.0.13",
6
6
  "channels": [
7
7
  "mixin"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invago/mixin",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Mixin Messenger channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -13,10 +13,13 @@
13
13
  "openclaw": ">=2026.2.0"
14
14
  },
15
15
  "dependencies": {
16
+ "@sinclair/typebox": "^0.34.48",
16
17
  "@mixin.dev/mixin-node-sdk": "^7.4.1",
17
18
  "@noble/curves": "^2.0.1",
18
19
  "@noble/hashes": "^2.0.1",
19
20
  "axios": "^1.13.6",
21
+ "ajv": "^8.18.0",
22
+ "ajv-formats": "^3.0.1",
20
23
  "express": "^5.2.1",
21
24
  "jiti": "^1.21.0",
22
25
  "proxy-agent": "^6.5.0",
package/src/channel.ts CHANGED
@@ -35,12 +35,43 @@ const conversationCategoryCache = new Map<string, {
35
35
  expiresAt: number;
36
36
  }>();
37
37
 
38
- function maskKey(key: string): string {
39
- if (!key || key.length < 8) {
40
- return "****";
41
- }
42
- return key.slice(0, 4) + "****" + key.slice(-4);
43
- }
38
+ function maskKey(key: string): string {
39
+ if (!key || key.length < 8) {
40
+ return "****";
41
+ }
42
+ return key.slice(0, 4) + "****" + key.slice(-4);
43
+ }
44
+
45
+ function extractQuoteMessageId(rawMsg: unknown): string | undefined {
46
+ const seen = new Set<unknown>();
47
+ const stack: unknown[] = [rawMsg];
48
+
49
+ while (stack.length > 0) {
50
+ const value = stack.pop();
51
+ if (!value || typeof value !== "object" || seen.has(value)) {
52
+ continue;
53
+ }
54
+ seen.add(value);
55
+
56
+ const record = value as Record<string, unknown>;
57
+ const quoteMessageId = record.quote_message_id;
58
+ if (typeof quoteMessageId === "string" && quoteMessageId.trim()) {
59
+ return quoteMessageId.trim();
60
+ }
61
+ const camelQuoteMessageId = record.quoteMessageId;
62
+ if (typeof camelQuoteMessageId === "string" && camelQuoteMessageId.trim()) {
63
+ return camelQuoteMessageId.trim();
64
+ }
65
+
66
+ for (const nested of Object.values(record)) {
67
+ if (nested && typeof nested === "object") {
68
+ stack.push(nested);
69
+ }
70
+ }
71
+ }
72
+
73
+ return undefined;
74
+ }
44
75
 
45
76
  async function resolveIsDirectMessage(params: {
46
77
  config: MixinAccountConfig;
@@ -429,18 +460,19 @@ export const mixinPlugin = {
429
460
  `[mixin] inbound route context: messageId=${rawMsg.message_id}, conversationId=${rawMsg.conversation_id ?? ""}, userId=${rawMsg.user_id}, isDirect=${isDirect}`,
430
461
  );
431
462
 
432
- const msg: MixinInboundMessage = {
433
- conversationId: rawMsg.conversation_id ?? "",
434
- userId: rawMsg.user_id,
435
- messageId: rawMsg.message_id,
436
- category: rawMsg.category ?? "PLAIN_TEXT",
437
- data: rawMsg.data_base64 ?? rawMsg.data ?? "",
438
- createdAt: rawMsg.created_at ?? new Date().toISOString(),
439
- };
440
-
441
- try {
442
- await handleMixinMessage({ cfg, accountId, msg, isDirect, log });
443
- } catch (err) {
463
+ const msg: MixinInboundMessage = {
464
+ conversationId: rawMsg.conversation_id ?? "",
465
+ userId: rawMsg.user_id,
466
+ messageId: rawMsg.message_id,
467
+ category: rawMsg.category ?? "PLAIN_TEXT",
468
+ data: rawMsg.data_base64 ?? rawMsg.data ?? "",
469
+ createdAt: rawMsg.created_at ?? new Date().toISOString(),
470
+ quoteMessageId: extractQuoteMessageId(rawMsg),
471
+ };
472
+
473
+ try {
474
+ await handleMixinMessage({ cfg, accountId, msg, isDirect, log });
475
+ } catch (err) {
444
476
  log.error(`error handling message ${msg.messageId}`, err);
445
477
  }
446
478
  },
@@ -1,6 +1,18 @@
1
- import { DmPolicySchema, GroupPolicySchema } from "openclaw/plugin-sdk";
2
1
  import { z } from "zod";
3
2
 
3
+ const DmPolicySchema = z.enum([
4
+ "pairing",
5
+ "allowlist",
6
+ "open",
7
+ "disabled",
8
+ ]);
9
+
10
+ const GroupPolicySchema = z.enum([
11
+ "open",
12
+ "disabled",
13
+ "allowlist",
14
+ ]);
15
+
4
16
  export const MixinProxyConfigSchema = z.object({
5
17
  enabled: z.boolean().optional().default(false),
6
18
  url: z.string().optional(),
@@ -16,17 +16,19 @@ import {
16
16
  purgePermanentInvalidOutboxEntries,
17
17
  sendTextMessage,
18
18
  } from "./send-service.js";
19
- import { buildClient } from "./shared.js";
20
-
21
- export interface MixinInboundMessage {
22
- conversationId: string;
23
- userId: string;
24
- messageId: string;
25
- category: string;
26
- data: string;
27
- createdAt: string;
28
- publicKey?: string;
29
- }
19
+ import { buildClient } from "./shared.js";
20
+ import { rememberMixinMessage, resolveMixinReplyContext } from "./message-context.js";
21
+
22
+ export interface MixinInboundMessage {
23
+ conversationId: string;
24
+ userId: string;
25
+ messageId: string;
26
+ category: string;
27
+ data: string;
28
+ createdAt: string;
29
+ quoteMessageId?: string;
30
+ publicKey?: string;
31
+ }
30
32
 
31
33
  const processedMessages = new Set<string>();
32
34
  const MAX_DEDUP_SIZE = 2000;
@@ -55,12 +57,19 @@ type CachedGroupProfile = {
55
57
  expiresAt: number;
56
58
  };
57
59
 
58
- type CachedBotProfile = {
59
- name: string;
60
- expiresAt: number;
61
- };
62
-
63
- type MixinAttachmentRequest = {
60
+ type CachedBotProfile = {
61
+ name: string;
62
+ expiresAt: number;
63
+ };
64
+
65
+ type CachedBotIdentity = {
66
+ name: string;
67
+ userId: string;
68
+ identityNumber: string;
69
+ expiresAt: number;
70
+ };
71
+
72
+ type MixinAttachmentRequest = {
64
73
  attachmentId: string;
65
74
  mimeType?: string;
66
75
  size?: number;
@@ -68,10 +77,11 @@ type MixinAttachmentRequest = {
68
77
  duration?: number;
69
78
  };
70
79
 
71
- const cachedUserProfiles = new Map<string, CachedUserProfile>();
72
- const cachedGroupProfiles = new Map<string, CachedGroupProfile>();
73
- const cachedBotProfiles = new Map<string, CachedBotProfile>();
74
- let cachedUpdateSessionStore:
80
+ const cachedUserProfiles = new Map<string, CachedUserProfile>();
81
+ const cachedGroupProfiles = new Map<string, CachedGroupProfile>();
82
+ const cachedBotProfiles = new Map<string, CachedBotProfile>();
83
+ const cachedBotIdentities = new Map<string, CachedBotIdentity>();
84
+ let cachedUpdateSessionStore:
75
85
  | ((storePath: string, mutator: (store: Record<string, Record<string, unknown>>) => void | Promise<void>) => Promise<unknown>)
76
86
  | null
77
87
  | undefined;
@@ -122,16 +132,20 @@ function pruneUnauthNotifiedGroups(now: number): void {
122
132
  }
123
133
  }
124
134
 
125
- function decodeContent(category: string, data: string): string {
126
- if (category.startsWith("PLAIN_TEXT") || category.startsWith("PLAIN_POST")) {
135
+ function decodeContent(category: string, data: string): string {
136
+ if (category.startsWith("PLAIN_TEXT") || category.startsWith("PLAIN_POST")) {
127
137
  try {
128
138
  return Buffer.from(data, "base64").toString("utf-8");
129
139
  } catch {
130
140
  return data;
131
141
  }
132
- }
133
- return `[${category}]`;
134
- }
142
+ }
143
+ return `[${category}]`;
144
+ }
145
+
146
+ function escapeRegExp(value: string): string {
147
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
+ }
135
149
 
136
150
  function buildUserProfileCacheKey(accountId: string, userId: string): string {
137
151
  return `${accountId}:${userId.trim().toLowerCase()}`;
@@ -346,41 +360,51 @@ async function resolveGroupName(params: {
346
360
  }
347
361
  }
348
362
 
349
- async function resolveBotName(params: {
350
- accountId: string;
351
- config: MixinAccountConfig;
352
- log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
353
- }): Promise<string> {
354
- const configuredName = normalizePresentationName(params.config.name ?? "");
355
- if (configuredName) {
356
- return configuredName;
357
- }
358
-
359
- const now = Date.now();
360
- const cacheKey = buildBotProfileCacheKey(params.accountId);
361
- const cached = cachedBotProfiles.get(cacheKey);
362
- if (cached && cached.expiresAt > now) {
363
- return cached.name;
364
- }
365
-
366
- pruneBotProfileCache(now);
367
-
368
- try {
369
- const client = buildClient(params.config);
370
- const profile = await client.user.profile();
371
- const name = normalizePresentationName(String(profile.full_name ?? "")) || params.accountId;
372
- cachedBotProfiles.set(cacheKey, {
373
- name,
374
- expiresAt: now + BOT_PROFILE_CACHE_TTL_MS,
375
- });
376
- return name;
377
- } catch (err) {
378
- params.log.warn(
379
- `[mixin] failed to resolve bot profile: accountId=${params.accountId}, error=${err instanceof Error ? err.message : String(err)}`,
380
- );
381
- return params.accountId;
382
- }
383
- }
363
+ async function resolveBotIdentity(params: {
364
+ accountId: string;
365
+ config: MixinAccountConfig;
366
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
367
+ }): Promise<CachedBotIdentity> {
368
+ const cacheKey = buildBotProfileCacheKey(params.accountId);
369
+ const cached = cachedBotIdentities.get(cacheKey);
370
+ if (cached && cached.expiresAt > Date.now()) {
371
+ return cached;
372
+ }
373
+
374
+ const now = Date.now();
375
+ const configuredName = normalizePresentationName(params.config.name ?? "");
376
+ const configuredIdentity: CachedBotIdentity = {
377
+ name: configuredName || params.accountId,
378
+ userId: params.config.appId?.trim() || params.accountId,
379
+ identityNumber: "",
380
+ expiresAt: now + BOT_PROFILE_CACHE_TTL_MS,
381
+ };
382
+ if (configuredName) {
383
+ cachedBotIdentities.set(cacheKey, configuredIdentity);
384
+ return configuredIdentity;
385
+ }
386
+
387
+ pruneBotProfileCache(now);
388
+
389
+ try {
390
+ const client = buildClient(params.config);
391
+ const profile = await client.user.profile();
392
+ const identity: CachedBotIdentity = {
393
+ name: normalizePresentationName(String(profile.full_name ?? "")) || params.accountId,
394
+ userId: normalizePresentationName(String(profile.user_id ?? "")) || params.config.appId?.trim() || params.accountId,
395
+ identityNumber: normalizePresentationName(String(profile.identity_number ?? "")),
396
+ expiresAt: now + BOT_PROFILE_CACHE_TTL_MS,
397
+ };
398
+ cachedBotIdentities.set(cacheKey, identity);
399
+ return identity;
400
+ } catch (err) {
401
+ params.log.warn(
402
+ `[mixin] failed to resolve bot profile: accountId=${params.accountId}, error=${err instanceof Error ? err.message : String(err)}`,
403
+ );
404
+ cachedBotIdentities.set(cacheKey, configuredIdentity);
405
+ return configuredIdentity;
406
+ }
407
+ }
384
408
 
385
409
  async function updateSessionPresentation(params: {
386
410
  storePath: string;
@@ -465,7 +489,7 @@ function parseInboundAttachmentRequest(category: string, data: string): MixinAtt
465
489
  }
466
490
  }
467
491
 
468
- function formatInboundAttachmentText(category: string, payload: MixinAttachmentRequest): string {
492
+ function formatInboundAttachmentText(category: string, payload: MixinAttachmentRequest): string {
469
493
  if (category === "PLAIN_AUDIO") {
470
494
  const details = [
471
495
  payload.fileName,
@@ -481,10 +505,48 @@ function formatInboundAttachmentText(category: string, payload: MixinAttachmentR
481
505
  payload.mimeType,
482
506
  typeof payload.size === "number" ? `${payload.size} bytes` : undefined,
483
507
  ].filter(Boolean);
484
- return details.length > 0 ? `[Mixin file] ${details.join(" | ")}` : "[Mixin file]";
485
- }
486
-
487
- async function resolveInboundAttachment(params: {
508
+ return details.length > 0 ? `[Mixin file] ${details.join(" | ")}` : "[Mixin file]";
509
+ }
510
+
511
+ function tryDecodeFallbackText(data: string): string | null {
512
+ if (!data) {
513
+ return null;
514
+ }
515
+
516
+ try {
517
+ const decoded = Buffer.from(data, "base64").toString("utf-8").replace(/^\uFEFF/, "").trim();
518
+ if (!decoded) {
519
+ return null;
520
+ }
521
+ const compact = decoded.replace(/\s+/g, "");
522
+ if (!compact) {
523
+ return null;
524
+ }
525
+ const printableCount = Array.from(compact).filter((char) => /[\p{L}\p{N}\p{P}\p{S}\u4e00-\u9fff]/u.test(char)).length;
526
+ if (printableCount / compact.length < 0.6) {
527
+ return null;
528
+ }
529
+ return decoded;
530
+ } catch {
531
+ return null;
532
+ }
533
+ }
534
+
535
+ function buildQuotedMessageContextNote(params: {
536
+ quoteMessageId: string;
537
+ found: boolean;
538
+ }): string[] {
539
+ if (params.found) {
540
+ return [];
541
+ }
542
+
543
+ return [
544
+ `Quoted message id: ${params.quoteMessageId}`,
545
+ "Quoted message body was not available in cache.",
546
+ ];
547
+ }
548
+
549
+ async function resolveInboundAttachment(params: {
488
550
  rt: ReturnType<typeof getMixinRuntime>;
489
551
  config: MixinAccountConfig;
490
552
  msg: MixinInboundMessage;
@@ -533,13 +595,30 @@ async function resolveInboundAttachment(params: {
533
595
  }
534
596
  }
535
597
 
536
- function shouldPassGroupFilter(config: MixinAccountConfig, text: string): boolean {
537
- if (!config.requireMentionInGroup) {
538
- return true;
539
- }
540
- if (text.trim().startsWith("/")) {
541
- return true;
542
- }
598
+ function hasBotMention(text: string, botName?: string): boolean {
599
+ const normalizedBotName = normalizePresentationName(botName ?? "").replace(/^@+/, "");
600
+ if (!normalizedBotName) {
601
+ return false;
602
+ }
603
+
604
+ const mentionPattern = new RegExp(`@\\s*${escapeRegExp(normalizedBotName)}(?=$|[\\s::,,.!?。;;、])`, "i");
605
+ return mentionPattern.test(text);
606
+ }
607
+
608
+ function shouldPassGroupFilter(
609
+ config: MixinAccountConfig,
610
+ text: string,
611
+ botAliases: string[] = [],
612
+ ): boolean {
613
+ if (!config.requireMentionInGroup) {
614
+ return true;
615
+ }
616
+ if (botAliases.some((alias) => hasBotMention(text, alias))) {
617
+ return true;
618
+ }
619
+ if (text.trim().startsWith("/")) {
620
+ return true;
621
+ }
543
622
  const lower = text.toLowerCase();
544
623
  return lower.includes("?") || /帮我|请|分析|总结|help/i.test(lower);
545
624
  }
@@ -865,43 +944,103 @@ export async function handleMixinMessage(params: {
865
944
  }
866
945
  }
867
946
 
868
- const isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
869
- const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
870
-
871
- if (!isTextMessage && !isAttachmentMessage) {
872
- log.info(`[mixin] skip non-text message: ${msg.category}`);
873
- return;
874
- }
875
-
876
- let text = decodeContent(msg.category, msg.data).trim();
877
- let mediaPayload: AgentMediaPayload | undefined;
878
- if (isAttachmentMessage) {
879
- const resolved = await resolveInboundAttachment({ rt, config, msg, log });
880
- text = resolved.text.trim();
881
- mediaPayload = resolved.mediaPayload;
882
- }
883
- log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
884
-
885
- if (!text) {
886
- return;
887
- }
888
-
889
- const conversationPolicy = isDirect
890
- ? null
891
- : resolveConversationPolicy(cfg, accountId, msg.conversationId);
892
-
893
- if (
894
- !isDirect &&
895
- conversationPolicy &&
896
- !(isAttachmentMessage && conversationPolicy.mediaBypassMention) &&
897
- !shouldPassGroupFilter({
898
- ...config,
899
- requireMentionInGroup: conversationPolicy.requireMention,
900
- }, text)
901
- ) {
902
- log.info(`[mixin] group message filtered: ${msg.messageId}`);
903
- return;
904
- }
947
+ let isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
948
+ const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
949
+
950
+ if (!isTextMessage && !isAttachmentMessage) {
951
+ const fallbackText = tryDecodeFallbackText(msg.data);
952
+ if (fallbackText) {
953
+ log.warn(
954
+ `[mixin] treating unexpected category as text: messageId=${msg.messageId}, category=${msg.category}, fallbackLength=${fallbackText.length}`,
955
+ );
956
+ msg.category = "PLAIN_TEXT";
957
+ msg.data = Buffer.from(fallbackText).toString("base64");
958
+ isTextMessage = true;
959
+ }
960
+ }
961
+
962
+ if (!isTextMessage && !isAttachmentMessage) {
963
+ log.info(
964
+ `[mixin] skip non-text message: messageId=${msg.messageId}, category=${msg.category}, quoteMessageId=${msg.quoteMessageId ?? "none"}`,
965
+ );
966
+ return;
967
+ }
968
+
969
+ const decodedBody = decodeContent(msg.category, msg.data);
970
+ let text = decodedBody.trim();
971
+ let mediaPayload: AgentMediaPayload | undefined;
972
+ if (isAttachmentMessage) {
973
+ const resolved = await resolveInboundAttachment({ rt, config, msg, log });
974
+ text = resolved.text.trim();
975
+ mediaPayload = resolved.mediaPayload;
976
+ }
977
+ log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
978
+
979
+ const botIdentity = await resolveBotIdentity({
980
+ accountId,
981
+ config,
982
+ log,
983
+ });
984
+ const replyContext = resolveMixinReplyContext({
985
+ accountId,
986
+ conversationId: msg.conversationId,
987
+ quoteMessageId: msg.quoteMessageId,
988
+ });
989
+ rememberMixinMessage({
990
+ accountId,
991
+ conversationId: msg.conversationId,
992
+ messageId: msg.messageId,
993
+ senderId: msg.userId,
994
+ body: text,
995
+ timestamp: msg.createdAt,
996
+ direction: "inbound",
997
+ quoteMessageId: msg.quoteMessageId,
998
+ });
999
+ if (replyContext?.found) {
1000
+ log.info(
1001
+ `[mixin] reply context resolved: messageId=${msg.messageId}, quoteMessageId=${replyContext.id}, sender=${replyContext.sender ?? "unknown"}`,
1002
+ );
1003
+ } else if (replyContext?.id) {
1004
+ log.info(
1005
+ `[mixin] reply context missing from cache: messageId=${msg.messageId}, quoteMessageId=${replyContext.id}`,
1006
+ );
1007
+ }
1008
+
1009
+ if (!text) {
1010
+ return;
1011
+ }
1012
+
1013
+ const conversationPolicy = isDirect
1014
+ ? null
1015
+ : resolveConversationPolicy(cfg, accountId, msg.conversationId);
1016
+
1017
+ const botAliases = [
1018
+ botIdentity.identityNumber,
1019
+ botIdentity.userId,
1020
+ ].filter((value): value is string => Boolean(value && value.trim()));
1021
+ const groupMentioned = !isDirect && botAliases.some((alias) => hasBotMention(text, alias));
1022
+ if (!isDirect) {
1023
+ log.info(
1024
+ `[mixin] group trigger check: messageId=${msg.messageId}, botName=${botIdentity.name}, botUserId=${botIdentity.userId}, botIdentityNumber=${botIdentity.identityNumber || "none"}, replyContext=${replyContext?.id ?? "none"}, mentioned=${groupMentioned}`,
1025
+ );
1026
+ }
1027
+
1028
+ if (
1029
+ !isDirect &&
1030
+ conversationPolicy &&
1031
+ !(isAttachmentMessage && conversationPolicy.mediaBypassMention) &&
1032
+ !shouldPassGroupFilter(
1033
+ {
1034
+ ...config,
1035
+ requireMentionInGroup: conversationPolicy.requireMention,
1036
+ },
1037
+ text,
1038
+ botAliases,
1039
+ )
1040
+ ) {
1041
+ log.info(`[mixin] group message filtered: ${msg.messageId}`);
1042
+ return;
1043
+ }
905
1044
 
906
1045
  const effectiveAllowFrom = await readEffectiveAllowFrom(rt, accountId, config.allowFrom, log);
907
1046
  const normalizedUserId = normalizeAllowEntry(msg.userId);
@@ -1115,33 +1254,28 @@ export async function handleMixinMessage(params: {
1115
1254
  })
1116
1255
  : undefined;
1117
1256
 
1118
- const senderName = await resolveSenderName({
1119
- accountId,
1120
- config,
1121
- userId: msg.userId,
1122
- log,
1123
- });
1124
- const botName = await resolveBotName({
1125
- accountId,
1126
- config,
1127
- log,
1128
- });
1129
- const groupName = isDirect
1130
- ? ""
1131
- : await resolveGroupName({
1257
+ const senderName = await resolveSenderName({
1258
+ accountId,
1259
+ config,
1260
+ userId: msg.userId,
1261
+ log,
1262
+ });
1263
+ const groupName = isDirect
1264
+ ? ""
1265
+ : await resolveGroupName({
1132
1266
  accountId,
1133
1267
  config,
1134
1268
  conversationId: msg.conversationId,
1135
1269
  log,
1136
1270
  });
1137
- const conversationLabel = isDirect
1138
- ? clampSessionLabel(`${botName}-${senderName || msg.userId}`)
1139
- : clampSessionLabel(`${botName}-${groupName || msg.conversationId}`);
1140
-
1141
- const ctx = rt.channel.reply.finalizeInboundContext({
1142
- Body: text,
1143
- RawBody: text,
1144
- CommandBody: text,
1271
+ const conversationLabel = isDirect
1272
+ ? clampSessionLabel(`${botIdentity.name}-${senderName || msg.userId}`)
1273
+ : clampSessionLabel(`${botIdentity.name}-${groupName || msg.conversationId}`);
1274
+
1275
+ const ctx = rt.channel.reply.finalizeInboundContext({
1276
+ Body: text,
1277
+ RawBody: text,
1278
+ CommandBody: text,
1145
1279
  From: isDirect ? msg.userId : msg.conversationId,
1146
1280
  SenderId: msg.userId,
1147
1281
  SenderName: senderName,
@@ -1150,14 +1284,22 @@ export async function handleMixinMessage(params: {
1150
1284
  ChatType: isDirect ? "direct" : "group",
1151
1285
  ConversationLabel: conversationLabel,
1152
1286
  GroupSubject: isDirect ? undefined : groupName || msg.conversationId,
1153
- Provider: "mixin",
1154
- Surface: "mixin",
1155
- MessageSid: msg.messageId,
1156
- CommandAuthorized: commandAuthorized,
1157
- OriginatingChannel: "mixin",
1158
- OriginatingTo: isDirect ? msg.userId : msg.conversationId,
1159
- ...mediaPayload,
1160
- });
1287
+ Provider: "mixin",
1288
+ Surface: "mixin",
1289
+ MessageSid: msg.messageId,
1290
+ ReplyToId: replyContext?.id,
1291
+ ReplyToBody: replyContext?.body,
1292
+ ReplyToSender: replyContext?.sender,
1293
+ ReplyToIsQuote: replyContext ? true : undefined,
1294
+ UntrustedContext: replyContext?.id ? buildQuotedMessageContextNote({
1295
+ quoteMessageId: replyContext.id,
1296
+ found: replyContext.found,
1297
+ }) : undefined,
1298
+ CommandAuthorized: commandAuthorized,
1299
+ OriginatingChannel: "mixin",
1300
+ OriginatingTo: isDirect ? msg.userId : msg.conversationId,
1301
+ ...mediaPayload,
1302
+ });
1161
1303
 
1162
1304
  const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
1163
1305
  agentId: route.agentId,
@@ -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
+ }
@@ -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
  );