@newbase-clawchat/openclaw-clawchat 2026.5.12-2 → 2026.5.12-21

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.
Files changed (99) hide show
  1. package/README.md +39 -17
  2. package/dist/index.js +3 -1
  3. package/dist/src/api-client.js +71 -12
  4. package/dist/src/api-types.test-d.js +10 -0
  5. package/dist/src/channel.js +5 -5
  6. package/dist/src/channel.setup.js +4 -17
  7. package/dist/src/clawchat-memory.js +290 -0
  8. package/dist/src/clawchat-metadata.js +235 -0
  9. package/dist/src/client.js +31 -93
  10. package/dist/src/commands.js +3 -3
  11. package/dist/src/config.js +58 -3
  12. package/dist/src/group-message-coalescer.js +107 -0
  13. package/dist/src/inbound.js +24 -28
  14. package/dist/src/login.runtime.js +82 -19
  15. package/dist/src/media-runtime.js +2 -3
  16. package/dist/src/message-mapper.js +1 -1
  17. package/dist/src/mock-transport.js +31 -0
  18. package/dist/src/outbound.js +281 -56
  19. package/dist/src/plugin-prompts.js +76 -0
  20. package/dist/src/profile-prompt.js +150 -0
  21. package/dist/src/profile-sync.js +169 -0
  22. package/dist/src/prompt-injection.js +25 -0
  23. package/dist/src/protocol-types.js +63 -0
  24. package/dist/src/protocol-types.typecheck.js +1 -0
  25. package/dist/src/protocol.js +2 -2
  26. package/dist/src/reply-dispatcher.js +143 -40
  27. package/dist/src/runtime.js +813 -109
  28. package/dist/src/storage.js +636 -0
  29. package/dist/src/tools-schema.js +70 -10
  30. package/dist/src/tools.js +600 -112
  31. package/dist/src/ws-alignment.js +8 -0
  32. package/dist/src/ws-client.js +588 -0
  33. package/index.ts +6 -1
  34. package/openclaw.plugin.json +44 -4
  35. package/package.json +4 -3
  36. package/prompts/platform.md +7 -0
  37. package/skills/clawchat/SKILL.md +90 -0
  38. package/src/api-client.test.ts +360 -15
  39. package/src/api-client.ts +127 -25
  40. package/src/api-types.test-d.ts +12 -0
  41. package/src/api-types.ts +71 -4
  42. package/src/buffered-stream.test.ts +1 -1
  43. package/src/buffered-stream.ts +1 -1
  44. package/src/channel.outbound.test.ts +270 -60
  45. package/src/channel.setup.ts +9 -18
  46. package/src/channel.test.ts +33 -25
  47. package/src/channel.ts +5 -7
  48. package/src/clawchat-memory.test.ts +372 -0
  49. package/src/clawchat-memory.ts +363 -0
  50. package/src/clawchat-metadata.test.ts +350 -0
  51. package/src/clawchat-metadata.ts +352 -0
  52. package/src/client.test.ts +57 -48
  53. package/src/client.ts +37 -129
  54. package/src/commands.test.ts +2 -2
  55. package/src/commands.ts +3 -3
  56. package/src/config.test.ts +169 -4
  57. package/src/config.ts +86 -6
  58. package/src/group-message-coalescer.test.ts +223 -0
  59. package/src/group-message-coalescer.ts +154 -0
  60. package/src/inbound.test.ts +106 -19
  61. package/src/inbound.ts +31 -35
  62. package/src/login.runtime.test.ts +294 -11
  63. package/src/login.runtime.ts +90 -21
  64. package/src/manifest.test.ts +86 -14
  65. package/src/media-runtime.test.ts +31 -2
  66. package/src/media-runtime.ts +7 -10
  67. package/src/message-mapper.test.ts +2 -2
  68. package/src/message-mapper.ts +2 -2
  69. package/src/mock-transport.test.ts +35 -0
  70. package/src/mock-transport.ts +38 -0
  71. package/src/outbound.test.ts +811 -95
  72. package/src/outbound.ts +332 -65
  73. package/src/plugin-entry.test.ts +3 -1
  74. package/src/plugin-prompts.test.ts +78 -0
  75. package/src/plugin-prompts.ts +92 -0
  76. package/src/profile-prompt.test.ts +435 -0
  77. package/src/profile-prompt.ts +208 -0
  78. package/src/profile-sync.test.ts +611 -0
  79. package/src/profile-sync.ts +268 -0
  80. package/src/prompt-injection.test.ts +39 -0
  81. package/src/prompt-injection.ts +45 -0
  82. package/src/protocol-types.test.ts +69 -0
  83. package/src/protocol-types.ts +296 -0
  84. package/src/protocol-types.typecheck.ts +89 -0
  85. package/src/protocol.ts +2 -2
  86. package/src/reply-dispatcher.test.ts +720 -135
  87. package/src/reply-dispatcher.ts +174 -42
  88. package/src/runtime.test.ts +3884 -337
  89. package/src/runtime.ts +956 -128
  90. package/src/storage.test.ts +692 -0
  91. package/src/storage.ts +989 -0
  92. package/src/streaming.test.ts +1 -1
  93. package/src/streaming.ts +1 -1
  94. package/src/tools-schema.ts +115 -13
  95. package/src/tools.test.ts +501 -10
  96. package/src/tools.ts +739 -133
  97. package/src/ws-alignment.ts +9 -0
  98. package/src/ws-client.test.ts +1218 -0
  99. package/src/ws-client.ts +662 -0
@@ -0,0 +1,154 @@
1
+ export type CoalescableGroupTurn = {
2
+ peer: { kind: "group"; id: string };
3
+ senderId: string;
4
+ senderNickName: string;
5
+ senderRelation?: "self_agent" | "owner" | "peer_agent" | "peer_user";
6
+ senderProfileType?: string | null;
7
+ senderIsOwner?: boolean;
8
+ rawBody: string;
9
+ messageId: string;
10
+ traceId: string;
11
+ timestamp: number;
12
+ wasMentioned: boolean;
13
+ mentionedUserIds: string[];
14
+ coalescedGroupBatch?: boolean;
15
+ mediaItems: unknown[];
16
+ replyCtx?: unknown;
17
+ envelope: unknown;
18
+ };
19
+
20
+ type PendingBatch<T extends CoalescableGroupTurn> = {
21
+ turns: T[];
22
+ idleTimer: ReturnType<typeof setTimeout>;
23
+ maxWaitTimer: ReturnType<typeof setTimeout>;
24
+ };
25
+
26
+ type CoalescedGroupTiming = {
27
+ idleSeconds: number;
28
+ maxWaitSeconds: number;
29
+ };
30
+
31
+ function formatTurnTime(timestamp: number): string {
32
+ if (!Number.isFinite(timestamp)) return "unknown-time";
33
+ const time = new Date(timestamp);
34
+ if (Number.isNaN(time.getTime())) return "unknown-time";
35
+ return time.toISOString();
36
+ }
37
+
38
+ function formatSenderRelation(turn: CoalescableGroupTurn): string {
39
+ return turn.senderRelation || "peer_user";
40
+ }
41
+
42
+ function formatSenderProfileType(turn: CoalescableGroupTurn): string {
43
+ if (turn.senderProfileType) return turn.senderProfileType;
44
+ const relation = formatSenderRelation(turn);
45
+ return relation === "self_agent" || relation === "peer_agent" ? "agent" : "user";
46
+ }
47
+
48
+ function formatMessageBody(rawBody: string): string {
49
+ return rawBody || "(empty message)";
50
+ }
51
+
52
+ function formatField(value: string): string {
53
+ return value.replace(/\\/g, "\\\\").replace(/\r/g, "\\r").replace(/\n/g, "\\n");
54
+ }
55
+
56
+ function formatMentionedUserIds(turn: CoalescableGroupTurn): string {
57
+ return turn.mentionedUserIds.length ? turn.mentionedUserIds.join(",") : "-";
58
+ }
59
+
60
+ export function formatCoalescedGroupBody(
61
+ turns: CoalescableGroupTurn[],
62
+ timing: CoalescedGroupTiming = { idleSeconds: 10, maxWaitSeconds: 30 },
63
+ ): string {
64
+ const header = `ClawChat group batch (${turns.length} ${turns.length === 1 ? "message" : "messages"}, ${timing.idleSeconds}s idle, ${timing.maxWaitSeconds}s max):`;
65
+ return [
66
+ header,
67
+ turns.map((turn) => {
68
+ const senderName = turn.senderNickName || turn.senderId;
69
+ const senderIsOwner = turn.senderIsOwner ?? formatSenderRelation(turn) === "owner";
70
+ return [
71
+ "[message]",
72
+ `sender_id: ${formatField(turn.senderId)}`,
73
+ `sender_name: ${formatField(senderName)}`,
74
+ `sender_profile_type: ${formatField(formatSenderProfileType(turn))}`,
75
+ `sender_is_owner: ${senderIsOwner ? "true" : "false"}`,
76
+ `mentions_current_agent: ${turn.wasMentioned ? "true" : "false"}`,
77
+ `mentioned_user_ids: ${formatField(formatMentionedUserIds(turn))}`,
78
+ "text:",
79
+ formatMessageBody(turn.rawBody),
80
+ ].join("\n");
81
+ }).join("\n\n"),
82
+ ].join("\n");
83
+ }
84
+
85
+ export function mergeGroupTurns<T extends CoalescableGroupTurn>(
86
+ turns: T[],
87
+ timing: CoalescedGroupTiming = { idleSeconds: 10, maxWaitSeconds: 30 },
88
+ ): T {
89
+ if (turns.length === 0) throw new Error("cannot merge empty group turn batch");
90
+ const latest = turns[turns.length - 1]!;
91
+ return {
92
+ ...latest,
93
+ rawBody: formatCoalescedGroupBody(turns, timing),
94
+ mediaItems: turns.flatMap((turn) => turn.mediaItems) as T["mediaItems"],
95
+ wasMentioned: turns.some((turn) => turn.wasMentioned),
96
+ mentionedUserIds: Array.from(new Set(turns.flatMap((turn) => turn.mentionedUserIds))),
97
+ coalescedGroupBatch: true,
98
+ };
99
+ }
100
+
101
+ export function createGroupMessageCoalescer<T extends CoalescableGroupTurn>(params: {
102
+ idleMs: number;
103
+ maxWaitMs: number;
104
+ dispatch: (turn: T) => Promise<void>;
105
+ onError?: (error: unknown) => void;
106
+ onDrop?: (chatId: string, count: number) => void;
107
+ }) {
108
+ const pending = new Map<string, PendingBatch<T>>();
109
+ const timing = {
110
+ idleSeconds: Math.round(params.idleMs / 1000),
111
+ maxWaitSeconds: Math.round(params.maxWaitMs / 1000),
112
+ };
113
+
114
+ const flush = (chatId: string) => {
115
+ const batch = pending.get(chatId);
116
+ if (!batch) return;
117
+ pending.delete(chatId);
118
+ clearTimeout(batch.idleTimer);
119
+ clearTimeout(batch.maxWaitTimer);
120
+ void params.dispatch(mergeGroupTurns(batch.turns, timing)).catch((error) => {
121
+ params.onError?.(error);
122
+ });
123
+ };
124
+
125
+ return {
126
+ enqueue(turn: T): void {
127
+ const chatId = turn.peer.id;
128
+ const existing = pending.get(chatId);
129
+ if (existing) {
130
+ existing.turns.push(turn);
131
+ clearTimeout(existing.idleTimer);
132
+ existing.idleTimer = setTimeout(() => flush(chatId), params.idleMs);
133
+ return;
134
+ }
135
+ const idleTimer = setTimeout(() => flush(chatId), params.idleMs);
136
+ const maxWaitTimer = setTimeout(() => flush(chatId), params.maxWaitMs);
137
+ pending.set(chatId, { turns: [turn], idleTimer, maxWaitTimer });
138
+ },
139
+ flushNow(chatId: string): void {
140
+ flush(chatId);
141
+ },
142
+ cancelAll(): void {
143
+ for (const [chatId, batch] of pending) {
144
+ clearTimeout(batch.idleTimer);
145
+ clearTimeout(batch.maxWaitTimer);
146
+ params.onDrop?.(chatId, batch.turns.length);
147
+ }
148
+ pending.clear();
149
+ },
150
+ pendingCount(chatId: string): number {
151
+ return pending.get(chatId)?.turns.length ?? 0;
152
+ },
153
+ };
154
+ }
@@ -1,8 +1,8 @@
1
- import type { Envelope, DownlinkMessageSendPayload } from "@newbase-clawchat/sdk";
1
+ import type { Envelope, MessagePayload } from "./protocol-types.ts";
2
2
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
3
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { describe, expect, it, vi } from "vitest";
4
4
  import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
5
- import { dispatchOpenclawClawlingInbound, _resetDedupForTest } from "./inbound.ts";
5
+ import { dispatchOpenclawClawlingInbound } from "./inbound.ts";
6
6
 
7
7
  function baseAccount(
8
8
  overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
@@ -16,7 +16,12 @@ function baseAccount(
16
16
  baseUrl: "",
17
17
  token: "tk",
18
18
  userId: "agent-1",
19
+ ownerUserId: "owner-1",
19
20
  replyMode: "static",
21
+ groupMode: "all",
22
+ groupCommandMode: "owner",
23
+ groups: {},
24
+ richInteractions: false,
20
25
  forwardThinking: true,
21
26
  forwardToolCalls: false,
22
27
  allowFrom: [],
@@ -38,13 +43,13 @@ function buildSendEnvelope(
38
43
  event: "message.send" | "message.reply";
39
44
  text: string;
40
45
  chatType: "direct" | "group";
41
- mentions: string[];
46
+ mentions: unknown[];
42
47
  reply: unknown;
43
48
  messageId: string;
44
49
  chatId: string;
45
50
  omitChatId: boolean;
46
51
  }> = {},
47
- ): Envelope<DownlinkMessageSendPayload> {
52
+ ): Envelope<MessagePayload> {
48
53
  const chatId = overrides.chatId ?? "chat-1";
49
54
  const chatType = overrides.chatType ?? "direct";
50
55
  return {
@@ -78,9 +83,9 @@ function buildSendEnvelope(
78
83
  started_at: null,
79
84
  completed_at: null,
80
85
  },
81
- } as DownlinkMessageSendPayload["message"],
86
+ } as MessagePayload["message"],
82
87
  },
83
- } as Envelope<DownlinkMessageSendPayload>;
88
+ } as Envelope<MessagePayload>;
84
89
  }
85
90
 
86
91
  function buildStreamEnvelope(
@@ -135,10 +140,6 @@ function buildStreamEnvelope(
135
140
  }
136
141
 
137
142
  describe("openclaw-clawchat inbound", () => {
138
- beforeEach(() => {
139
- _resetDedupForTest();
140
- });
141
-
142
143
  it("dispatches a plain text message and flattens the body", async () => {
143
144
  const ingest = vi.fn().mockResolvedValue(undefined);
144
145
  await dispatchOpenclawClawlingInbound({
@@ -177,7 +178,7 @@ describe("openclaw-clawchat inbound", () => {
177
178
  event: "message.done",
178
179
  messageId: "stream-1",
179
180
  fragments: [{ kind: "text", text: "completed stream" }],
180
- }) as Envelope<DownlinkMessageSendPayload>,
181
+ }) as Envelope<MessagePayload>,
181
182
  cfg: {},
182
183
  runtime: {} as never,
183
184
  account: baseAccount(),
@@ -207,7 +208,7 @@ describe("openclaw-clawchat inbound", () => {
207
208
  }),
208
209
  ]) {
209
210
  await dispatchOpenclawClawlingInbound({
210
- envelope: envelope as Envelope<DownlinkMessageSendPayload>,
211
+ envelope: envelope as Envelope<MessagePayload>,
211
212
  cfg: {},
212
213
  runtime: {} as never,
213
214
  account: baseAccount(),
@@ -231,7 +232,7 @@ describe("openclaw-clawchat inbound", () => {
231
232
  { kind: "mention", user_id: "agent-1", display: "@bot" },
232
233
  { kind: "text", text: " please help" },
233
234
  ],
234
- }) as Envelope<DownlinkMessageSendPayload>,
235
+ }) as Envelope<MessagePayload>,
235
236
  cfg: {},
236
237
  runtime: {} as never,
237
238
  account: baseAccount({ groupMode: "mention" }),
@@ -255,7 +256,7 @@ describe("openclaw-clawchat inbound", () => {
255
256
  (envelope.payload as { streaming: { status: string } }).streaming.status = "failed";
256
257
 
257
258
  await dispatchOpenclawClawlingInbound({
258
- envelope: envelope as Envelope<DownlinkMessageSendPayload>,
259
+ envelope: envelope as Envelope<MessagePayload>,
259
260
  cfg: {},
260
261
  runtime: {} as never,
261
262
  account: baseAccount(),
@@ -269,7 +270,7 @@ describe("openclaw-clawchat inbound", () => {
269
270
  for (const event of ["message.created", "message.add", "message.failed"] as const) {
270
271
  const ingest = vi.fn().mockResolvedValue(undefined);
271
272
  await dispatchOpenclawClawlingInbound({
272
- envelope: buildStreamEnvelope({ event, messageId: `msg-${event}` }) as Envelope<DownlinkMessageSendPayload>,
273
+ envelope: buildStreamEnvelope({ event, messageId: `msg-${event}` }) as Envelope<MessagePayload>,
273
274
  cfg: {},
274
275
  runtime: {} as never,
275
276
  account: baseAccount(),
@@ -303,6 +304,41 @@ describe("openclaw-clawchat inbound", () => {
303
304
  ).toBe(true);
304
305
  });
305
306
 
307
+ it("detects object-shaped context mentions in group mention mode", async () => {
308
+ const ingest = vi.fn().mockResolvedValue(undefined);
309
+ await dispatchOpenclawClawlingInbound({
310
+ envelope: buildSendEnvelope({
311
+ chatType: "group",
312
+ mentions: [{ user_id: "agent-1", display: "@bot" }],
313
+ }),
314
+ cfg: {},
315
+ runtime: {} as never,
316
+ account: baseAccount({ groupMode: "mention" }),
317
+ ingest,
318
+ });
319
+
320
+ expect(ingest).toHaveBeenCalledTimes(1);
321
+ expect(ingest.mock.calls[0]![0].wasMentioned).toBe(true);
322
+ });
323
+
324
+ it("preserves mentioned user ids when groupMode all mentions another user", async () => {
325
+ const ingest = vi.fn().mockResolvedValue(undefined);
326
+ await dispatchOpenclawClawlingInbound({
327
+ envelope: buildSendEnvelope({
328
+ chatType: "group",
329
+ mentions: ["other-user"],
330
+ }),
331
+ cfg: {},
332
+ runtime: {} as never,
333
+ account: baseAccount({ groupMode: "all" }),
334
+ ingest,
335
+ });
336
+
337
+ expect(ingest).toHaveBeenCalledTimes(1);
338
+ expect(ingest.mock.calls[0]![0].wasMentioned).toBe(false);
339
+ expect(ingest.mock.calls[0]![0].mentionedUserIds).toEqual(["other-user"]);
340
+ });
341
+
306
342
  it("detectMention returns false for group chat when userId not mentioned", async () => {
307
343
  const { detectMention } = await import("./inbound.ts");
308
344
  expect(
@@ -326,6 +362,57 @@ describe("openclaw-clawchat inbound", () => {
326
362
  expect(ingest).not.toHaveBeenCalled();
327
363
  });
328
364
 
365
+ it("uses exact per-group mention mode for matching group chat_id", async () => {
366
+ const ingest = vi.fn().mockResolvedValue(undefined);
367
+ await dispatchOpenclawClawlingInbound({
368
+ envelope: buildSendEnvelope({ chatType: "group", chatId: "group-quiet" }),
369
+ cfg: {},
370
+ runtime: {} as never,
371
+ account: baseAccount({
372
+ groupMode: "all",
373
+ groups: { "group-quiet": { groupMode: "mention", groupCommandMode: "owner" } },
374
+ }),
375
+ ingest,
376
+ });
377
+
378
+ expect(ingest).not.toHaveBeenCalled();
379
+ });
380
+
381
+ it("uses wildcard per-group mention mode when exact group is absent", async () => {
382
+ const ingest = vi.fn().mockResolvedValue(undefined);
383
+ await dispatchOpenclawClawlingInbound({
384
+ envelope: buildSendEnvelope({ chatType: "group", chatId: "group-any" }),
385
+ cfg: {},
386
+ runtime: {} as never,
387
+ account: baseAccount({
388
+ groupMode: "all",
389
+ groups: { "*": { groupMode: "mention", groupCommandMode: "owner" } },
390
+ }),
391
+ ingest,
392
+ });
393
+
394
+ expect(ingest).not.toHaveBeenCalled();
395
+ });
396
+
397
+ it("lets exact per-group all mode override wildcard mention mode", async () => {
398
+ const ingest = vi.fn().mockResolvedValue(undefined);
399
+ await dispatchOpenclawClawlingInbound({
400
+ envelope: buildSendEnvelope({ chatType: "group", chatId: "group-open" }),
401
+ cfg: {},
402
+ runtime: {} as never,
403
+ account: baseAccount({
404
+ groupMode: "mention",
405
+ groups: {
406
+ "group-open": { groupMode: "all", groupCommandMode: "owner" },
407
+ "*": { groupMode: "mention", groupCommandMode: "owner" },
408
+ },
409
+ }),
410
+ ingest,
411
+ });
412
+
413
+ expect(ingest).toHaveBeenCalledTimes(1);
414
+ });
415
+
329
416
  it("skips messages with empty renderable text", async () => {
330
417
  const ingest = vi.fn().mockResolvedValue(undefined);
331
418
  await dispatchOpenclawClawlingInbound({
@@ -365,7 +452,7 @@ describe("openclaw-clawchat inbound", () => {
365
452
  });
366
453
  });
367
454
 
368
- it("deduplicates repeat message_ids", async () => {
455
+ it("does not own duplicate suppression when the same message_id is parsed twice", async () => {
369
456
  const ingest = vi.fn().mockResolvedValue(undefined);
370
457
  const env = buildSendEnvelope({ messageId: "dup-1" });
371
458
  await dispatchOpenclawClawlingInbound({
@@ -382,13 +469,13 @@ describe("openclaw-clawchat inbound", () => {
382
469
  account: baseAccount(),
383
470
  ingest,
384
471
  });
385
- expect(ingest).toHaveBeenCalledTimes(1);
472
+ expect(ingest).toHaveBeenCalledTimes(2);
386
473
  });
387
474
 
388
475
  it("passes mediaItems extracted from body fragments to ingest", async () => {
389
476
  const ingest = vi.fn().mockResolvedValue(undefined);
390
477
  const env = buildSendEnvelope({});
391
- // Replace the body's fragments with text + image (SDK 0.2.0 typed union accepts these directly)
478
+ // Replace the body's fragments with text + image; the local fragment union accepts these directly.
392
479
  env.payload.message.body.fragments = [
393
480
  { kind: "text", text: "hello" },
394
481
  { kind: "image", url: "https://cdn/x.png", mime: "image/png" },
package/src/inbound.ts CHANGED
@@ -2,9 +2,9 @@ import {
2
2
  EVENT,
3
3
  type ChatType,
4
4
  type Envelope,
5
- } from "@newbase-clawchat/sdk";
5
+ } from "./protocol-types.ts";
6
6
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
7
- import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
7
+ import { effectiveGroupMode, type ResolvedOpenclawClawlingAccount } from "./config.ts";
8
8
  import type { MediaItem } from "./media-runtime.ts";
9
9
  import { extractMediaFragments, fragmentsToText } from "./message-mapper.ts";
10
10
  import { hasRenderableText, isInboundMessagePayload } from "./protocol.ts";
@@ -20,11 +20,16 @@ export interface IngestTurnParams {
20
20
  peer: { kind: "direct" | "group"; id: string };
21
21
  senderId: string;
22
22
  senderNickName: string;
23
+ senderRelation?: "self_agent" | "owner" | "peer_agent" | "peer_user";
24
+ senderProfileType?: string | null;
25
+ senderIsOwner?: boolean;
23
26
  rawBody: string;
24
27
  messageId: string;
25
28
  traceId: string;
26
29
  timestamp: number;
27
30
  wasMentioned: boolean;
31
+ mentionedUserIds: string[];
32
+ coalescedGroupBatch?: boolean;
28
33
  mediaItems: MediaItem[];
29
34
  replyCtx?: {
30
35
  replyToMessageId: string;
@@ -48,23 +53,20 @@ export interface DispatchInboundParams {
48
53
  log?: { info?: (m: string) => void; error?: (m: string) => void };
49
54
  }
50
55
 
51
- const DEDUP_MAX = 256;
52
- const dedupSeen: string[] = [];
53
- const dedupSet = new Set<string>();
54
-
55
56
  type SenderLike = {
56
57
  id?: unknown;
57
58
  nick_name?: unknown;
58
59
  type?: unknown;
59
60
  };
60
61
 
61
- function normalizeSender(sender: unknown): { id: string; nickName: string } | null {
62
+ function normalizeSender(sender: unknown): { id: string; nickName: string; profileType: string | null } | null {
62
63
  if (!sender || typeof sender !== "object") return null;
63
64
  const s = sender as SenderLike;
64
65
  const id = typeof s.id === "string" ? s.id : "";
65
66
  if (!id) return null;
66
67
  const nickName = typeof s.nick_name === "string" ? s.nick_name : id;
67
- return { id, nickName };
68
+ const profileType = s.type === "agent" || s.type === "user" ? s.type : null;
69
+ return { id, nickName, profileType };
68
70
  }
69
71
 
70
72
  function isStreamDonePayload(payload: unknown): payload is {
@@ -93,20 +95,17 @@ function extractMentionIds(fragments: Array<Record<string, unknown>>): string[]
93
95
  .filter((userId): userId is string => typeof userId === "string" && userId.length > 0);
94
96
  }
95
97
 
96
- export function _resetDedupForTest(): void {
97
- dedupSeen.length = 0;
98
- dedupSet.clear();
99
- }
100
-
101
- function rememberAndCheck(messageId: string): boolean {
102
- if (dedupSet.has(messageId)) return true;
103
- dedupSet.add(messageId);
104
- dedupSeen.push(messageId);
105
- if (dedupSeen.length > DEDUP_MAX) {
106
- const evict = dedupSeen.shift();
107
- if (evict) dedupSet.delete(evict);
108
- }
109
- return false;
98
+ function normalizeMentionIds(mentions: unknown[]): string[] {
99
+ return mentions
100
+ .map((mention) => {
101
+ if (typeof mention === "string") return mention;
102
+ if (mention && typeof mention === "object") {
103
+ const userId = (mention as { user_id?: unknown }).user_id;
104
+ return typeof userId === "string" ? userId : undefined;
105
+ }
106
+ return undefined;
107
+ })
108
+ .filter((userId): userId is string => typeof userId === "string" && userId.length > 0);
110
109
  }
111
110
 
112
111
  /**
@@ -114,12 +113,12 @@ function rememberAndCheck(messageId: string): boolean {
114
113
  * group chats require a mention unless config opts into all group messages.
115
114
  */
116
115
  export function detectMention(params: {
117
- mentions: string[];
116
+ mentions: unknown[];
118
117
  chatType: "direct" | "group";
119
118
  userId: string;
120
119
  }): boolean {
121
120
  if (params.chatType === "direct") return true;
122
- return params.mentions.includes(params.userId);
121
+ return normalizeMentionIds(params.mentions).includes(params.userId);
123
122
  }
124
123
 
125
124
  export async function dispatchOpenclawClawlingInbound(
@@ -167,7 +166,7 @@ export async function dispatchOpenclawClawlingInbound(
167
166
  }) as {
168
167
  body: { fragments: Array<Record<string, unknown>> };
169
168
  context: {
170
- mentions: string[];
169
+ mentions: unknown[];
171
170
  reply: {
172
171
  reply_to_msg_id: string;
173
172
  reply_preview: {
@@ -200,19 +199,13 @@ export async function dispatchOpenclawClawlingInbound(
200
199
  );
201
200
  return;
202
201
  }
203
- if (rememberAndCheck(payload.message_id)) {
204
- log?.info?.(
205
- `[${account.accountId}] openclaw-clawchat skip duplicate msg=${payload.message_id}`,
206
- );
207
- return;
208
- }
209
-
202
+ const mentionIds = normalizeMentionIds(message.context.mentions);
210
203
  const rawBody = fragmentsToText(message.body.fragments as never, {
211
- mentionFallbackIds: message.context.mentions,
204
+ mentionFallbackIds: mentionIds,
212
205
  });
213
206
  const mediaItems = extractMediaFragments(message.body.fragments as never);
214
207
  const wasMentioned = detectMention({
215
- mentions: message.context.mentions,
208
+ mentions: mentionIds,
216
209
  chatType,
217
210
  userId: account.userId,
218
211
  });
@@ -220,7 +213,8 @@ export async function dispatchOpenclawClawlingInbound(
220
213
  // Group trigger policy: in "mention" mode we only handle group messages
221
214
  // that @-mention us; "all" listens open and processes every group msg.
222
215
  // Direct chats are unaffected (detectMention returns true).
223
- if (isGroup && account.groupMode === "mention" && !wasMentioned) {
216
+ const groupMode = isGroup ? effectiveGroupMode(account, chatId) : account.groupMode;
217
+ if (isGroup && groupMode === "mention" && !wasMentioned) {
224
218
  log?.info?.(
225
219
  `[${account.accountId}] openclaw-clawchat skip group (no mention) msg=${payload.message_id}`,
226
220
  );
@@ -247,11 +241,13 @@ export async function dispatchOpenclawClawlingInbound(
247
241
  peer: { kind: isGroup ? "group" : "direct", id: chatId },
248
242
  senderId: sender.id,
249
243
  senderNickName: sender.nickName,
244
+ ...(sender.profileType ? { senderProfileType: sender.profileType } : {}),
250
245
  rawBody,
251
246
  messageId: payload.message_id,
252
247
  traceId: envelope.trace_id,
253
248
  timestamp: envelope.emitted_at,
254
249
  wasMentioned,
250
+ mentionedUserIds: mentionIds,
255
251
  mediaItems,
256
252
  ...(replyCtx ? { replyCtx } : {}),
257
253
  cfg: params.cfg,