@clawling/clawchat-plugin-openclaw 2026.5.12-28

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 (114) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +227 -0
  3. package/dist/index.js +20 -0
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +263 -0
  6. package/dist/src/api-types.js +17 -0
  7. package/dist/src/api-types.test-d.js +10 -0
  8. package/dist/src/buffered-stream.js +177 -0
  9. package/dist/src/channel.js +66 -0
  10. package/dist/src/channel.setup.js +119 -0
  11. package/dist/src/clawchat-memory.js +403 -0
  12. package/dist/src/clawchat-metadata.js +310 -0
  13. package/dist/src/client.js +35 -0
  14. package/dist/src/commands.js +35 -0
  15. package/dist/src/config.js +274 -0
  16. package/dist/src/group-message-coalescer.js +119 -0
  17. package/dist/src/inbound.js +170 -0
  18. package/dist/src/llm-context-debug.js +86 -0
  19. package/dist/src/login.runtime.js +204 -0
  20. package/dist/src/media-runtime.js +85 -0
  21. package/dist/src/message-mapper.js +146 -0
  22. package/dist/src/mock-transport.js +31 -0
  23. package/dist/src/outbound.js +628 -0
  24. package/dist/src/plugin-prompts.js +89 -0
  25. package/dist/src/profile-prompt.js +269 -0
  26. package/dist/src/profile-sync.js +110 -0
  27. package/dist/src/prompt-injection.js +25 -0
  28. package/dist/src/protocol-types.js +63 -0
  29. package/dist/src/protocol-types.typecheck.js +1 -0
  30. package/dist/src/protocol.js +33 -0
  31. package/dist/src/reply-dispatcher.js +422 -0
  32. package/dist/src/runtime.js +1254 -0
  33. package/dist/src/storage.js +525 -0
  34. package/dist/src/streaming.js +65 -0
  35. package/dist/src/terminal-send.js +36 -0
  36. package/dist/src/tools-schema.js +208 -0
  37. package/dist/src/tools.js +920 -0
  38. package/dist/src/ws-alignment.js +178 -0
  39. package/dist/src/ws-client.js +588 -0
  40. package/dist/src/ws-log.js +19 -0
  41. package/index.ts +24 -0
  42. package/openclaw.plugin.json +169 -0
  43. package/package.json +80 -0
  44. package/prompts/default-group-bio.md +19 -0
  45. package/prompts/default-owner-behavior.md +27 -0
  46. package/prompts/platform.md +13 -0
  47. package/setup-entry.ts +4 -0
  48. package/skills/clawchat/SKILL.md +91 -0
  49. package/src/api-client.test.ts +827 -0
  50. package/src/api-client.ts +414 -0
  51. package/src/api-types.ts +146 -0
  52. package/src/channel.outbound.test.ts +433 -0
  53. package/src/channel.setup.ts +145 -0
  54. package/src/channel.test.ts +262 -0
  55. package/src/channel.ts +81 -0
  56. package/src/clawchat-memory.test.ts +480 -0
  57. package/src/clawchat-memory.ts +533 -0
  58. package/src/clawchat-metadata.test.ts +477 -0
  59. package/src/clawchat-metadata.ts +429 -0
  60. package/src/client.test.ts +169 -0
  61. package/src/client.ts +56 -0
  62. package/src/commands.test.ts +39 -0
  63. package/src/commands.ts +41 -0
  64. package/src/config.test.ts +344 -0
  65. package/src/config.ts +404 -0
  66. package/src/group-message-coalescer.test.ts +237 -0
  67. package/src/group-message-coalescer.ts +171 -0
  68. package/src/inbound.test.ts +508 -0
  69. package/src/inbound.ts +278 -0
  70. package/src/llm-context-debug.test.ts +55 -0
  71. package/src/llm-context-debug.ts +139 -0
  72. package/src/login.runtime.test.ts +737 -0
  73. package/src/login.runtime.ts +277 -0
  74. package/src/manifest.test.ts +352 -0
  75. package/src/media-runtime.test.ts +207 -0
  76. package/src/media-runtime.ts +152 -0
  77. package/src/message-mapper.test.ts +201 -0
  78. package/src/message-mapper.ts +174 -0
  79. package/src/mock-transport.test.ts +35 -0
  80. package/src/mock-transport.ts +38 -0
  81. package/src/outbound.test.ts +1269 -0
  82. package/src/outbound.ts +803 -0
  83. package/src/plugin-entry.test.ts +38 -0
  84. package/src/plugin-prompts.test.ts +94 -0
  85. package/src/plugin-prompts.ts +107 -0
  86. package/src/profile-prompt.test.ts +274 -0
  87. package/src/profile-prompt.ts +351 -0
  88. package/src/profile-sync.test.ts +539 -0
  89. package/src/profile-sync.ts +191 -0
  90. package/src/prompt-injection.test.ts +39 -0
  91. package/src/prompt-injection.ts +45 -0
  92. package/src/protocol-types.test.ts +69 -0
  93. package/src/protocol-types.ts +296 -0
  94. package/src/protocol-types.typecheck.ts +89 -0
  95. package/src/protocol.test.ts +39 -0
  96. package/src/protocol.ts +42 -0
  97. package/src/reply-dispatcher.test.ts +1324 -0
  98. package/src/reply-dispatcher.ts +555 -0
  99. package/src/runtime.test.ts +4719 -0
  100. package/src/runtime.ts +1493 -0
  101. package/src/scripts.test.ts +85 -0
  102. package/src/storage.test.ts +560 -0
  103. package/src/storage.ts +807 -0
  104. package/src/terminal-send.test.ts +81 -0
  105. package/src/terminal-send.ts +56 -0
  106. package/src/tools-schema.ts +337 -0
  107. package/src/tools.test.ts +933 -0
  108. package/src/tools.ts +1185 -0
  109. package/src/ws-alignment.test.ts +103 -0
  110. package/src/ws-alignment.ts +275 -0
  111. package/src/ws-client.test.ts +1217 -0
  112. package/src/ws-client.ts +662 -0
  113. package/src/ws-log.test.ts +32 -0
  114. package/src/ws-log.ts +31 -0
@@ -0,0 +1,171 @@
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
+ senderIsGroupOwner?: boolean;
9
+ rawBody: string;
10
+ messageId: string;
11
+ traceId: string;
12
+ timestamp: number;
13
+ wasMentioned: boolean;
14
+ mentionedUserIds: string[];
15
+ mentionedUsers?: { id: string; display?: string }[];
16
+ coalescedGroupBatch?: boolean;
17
+ mediaItems: unknown[];
18
+ replyCtx?: unknown;
19
+ envelope: unknown;
20
+ };
21
+
22
+ type PendingBatch<T extends CoalescableGroupTurn> = {
23
+ turns: T[];
24
+ idleTimer: ReturnType<typeof setTimeout>;
25
+ maxWaitTimer: ReturnType<typeof setTimeout>;
26
+ };
27
+
28
+ type CoalescedGroupTiming = {
29
+ idleSeconds: number;
30
+ maxWaitSeconds: number;
31
+ };
32
+
33
+ function formatTurnTime(timestamp: number): string {
34
+ if (!Number.isFinite(timestamp)) return "unknown-time";
35
+ const time = new Date(timestamp);
36
+ if (Number.isNaN(time.getTime())) return "unknown-time";
37
+ return time.toISOString();
38
+ }
39
+
40
+ function formatSenderRelation(turn: CoalescableGroupTurn): string {
41
+ return turn.senderRelation || "peer_user";
42
+ }
43
+
44
+ function formatSenderProfileType(turn: CoalescableGroupTurn): string {
45
+ if (turn.senderProfileType) return turn.senderProfileType;
46
+ const relation = formatSenderRelation(turn);
47
+ return relation === "self_agent" || relation === "peer_agent" ? "agent" : "user";
48
+ }
49
+
50
+ function formatMessageBody(rawBody: string): string {
51
+ return rawBody || "(empty message)";
52
+ }
53
+
54
+ function formatField(value: string): string {
55
+ return value.replace(/\\/g, "\\\\").replace(/\r/g, "\\r").replace(/\n/g, "\\n");
56
+ }
57
+
58
+ function formatMentionedUsers(turn: CoalescableGroupTurn): string {
59
+ const mentionedUsers: { id: string; display?: string }[] = turn.mentionedUsers && turn.mentionedUsers.length > 0
60
+ ? turn.mentionedUsers
61
+ : turn.mentionedUserIds.map((id) => ({ id }));
62
+ if (mentionedUsers.length === 0) return "-";
63
+ return mentionedUsers.map((mention) => {
64
+ const id = formatField(mention.id);
65
+ const display = mention.display?.trim();
66
+ return display ? `${id}(${formatField(display)})` : id;
67
+ }).join(",");
68
+ }
69
+
70
+ export function formatCoalescedGroupBody(
71
+ turns: CoalescableGroupTurn[],
72
+ timing: CoalescedGroupTiming = { idleSeconds: 10, maxWaitSeconds: 30 },
73
+ ): string {
74
+ const header = `ClawChat group batch (${turns.length} ${turns.length === 1 ? "message" : "messages"}, ${timing.idleSeconds}s idle, ${timing.maxWaitSeconds}s max):`;
75
+ return [
76
+ header,
77
+ turns.map((turn) => {
78
+ const senderName = turn.senderNickName || turn.senderId;
79
+ const senderIsAgentOwner = turn.senderIsOwner ?? formatSenderRelation(turn) === "owner";
80
+ return [
81
+ "[message]",
82
+ `sender_id: ${formatField(turn.senderId)}`,
83
+ `sender_name: ${formatField(senderName)}`,
84
+ `sender_profile_type: ${formatField(formatSenderProfileType(turn))}`,
85
+ `sender_is_agent_owner: ${senderIsAgentOwner ? "true" : "false"}`,
86
+ `sender_is_group_owner: ${turn.senderIsGroupOwner ? "true" : "false"}`,
87
+ `mentions_current_agent: ${turn.wasMentioned ? "true" : "false"}`,
88
+ `mentioned_users: ${formatMentionedUsers(turn)}`,
89
+ "text:",
90
+ formatMessageBody(turn.rawBody),
91
+ ].join("\n");
92
+ }).join("\n\n"),
93
+ ].join("\n");
94
+ }
95
+
96
+ export function mergeGroupTurns<T extends CoalescableGroupTurn>(
97
+ turns: T[],
98
+ timing: CoalescedGroupTiming = { idleSeconds: 10, maxWaitSeconds: 30 },
99
+ ): T {
100
+ if (turns.length === 0) throw new Error("cannot merge empty group turn batch");
101
+ const latest = turns[turns.length - 1]!;
102
+ return {
103
+ ...latest,
104
+ rawBody: formatCoalescedGroupBody(turns, timing),
105
+ mediaItems: turns.flatMap((turn) => turn.mediaItems) as T["mediaItems"],
106
+ wasMentioned: turns.some((turn) => turn.wasMentioned),
107
+ mentionedUserIds: Array.from(new Set(turns.flatMap((turn) => turn.mentionedUserIds))),
108
+ mentionedUsers: Array.from(
109
+ new Map(
110
+ turns.flatMap((turn) => turn.mentionedUsers ?? turn.mentionedUserIds.map((id) => ({ id })))
111
+ .map((mention) => [mention.id, mention]),
112
+ ).values(),
113
+ ),
114
+ coalescedGroupBatch: true,
115
+ };
116
+ }
117
+
118
+ export function createGroupMessageCoalescer<T extends CoalescableGroupTurn>(params: {
119
+ idleMs: number;
120
+ maxWaitMs: number;
121
+ dispatch: (turn: T) => Promise<void>;
122
+ onError?: (error: unknown) => void;
123
+ onDrop?: (chatId: string, count: number) => void;
124
+ }) {
125
+ const pending = new Map<string, PendingBatch<T>>();
126
+ const timing = {
127
+ idleSeconds: Math.round(params.idleMs / 1000),
128
+ maxWaitSeconds: Math.round(params.maxWaitMs / 1000),
129
+ };
130
+
131
+ const flush = (chatId: string) => {
132
+ const batch = pending.get(chatId);
133
+ if (!batch) return;
134
+ pending.delete(chatId);
135
+ clearTimeout(batch.idleTimer);
136
+ clearTimeout(batch.maxWaitTimer);
137
+ void params.dispatch(mergeGroupTurns(batch.turns, timing)).catch((error) => {
138
+ params.onError?.(error);
139
+ });
140
+ };
141
+
142
+ return {
143
+ enqueue(turn: T): void {
144
+ const chatId = turn.peer.id;
145
+ const existing = pending.get(chatId);
146
+ if (existing) {
147
+ existing.turns.push(turn);
148
+ clearTimeout(existing.idleTimer);
149
+ existing.idleTimer = setTimeout(() => flush(chatId), params.idleMs);
150
+ return;
151
+ }
152
+ const idleTimer = setTimeout(() => flush(chatId), params.idleMs);
153
+ const maxWaitTimer = setTimeout(() => flush(chatId), params.maxWaitMs);
154
+ pending.set(chatId, { turns: [turn], idleTimer, maxWaitTimer });
155
+ },
156
+ flushNow(chatId: string): void {
157
+ flush(chatId);
158
+ },
159
+ cancelAll(): void {
160
+ for (const [chatId, batch] of pending) {
161
+ clearTimeout(batch.idleTimer);
162
+ clearTimeout(batch.maxWaitTimer);
163
+ params.onDrop?.(chatId, batch.turns.length);
164
+ }
165
+ pending.clear();
166
+ },
167
+ pendingCount(chatId: string): number {
168
+ return pending.get(chatId)?.turns.length ?? 0;
169
+ },
170
+ };
171
+ }
@@ -0,0 +1,508 @@
1
+ import type { Envelope, MessagePayload } from "./protocol-types.ts";
2
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
5
+ import { dispatchOpenclawClawlingInbound } from "./inbound.ts";
6
+
7
+ function baseAccount(
8
+ overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
9
+ ): ResolvedOpenclawClawlingAccount {
10
+ return {
11
+ accountId: DEFAULT_ACCOUNT_ID,
12
+ name: "clawchat-plugin-openclaw",
13
+ enabled: true,
14
+ configured: true,
15
+ websocketUrl: "ws://t",
16
+ baseUrl: "",
17
+ token: "tk",
18
+ agentId: "",
19
+ userId: "agent-1",
20
+ ownerUserId: "owner-1",
21
+ groupMode: "all",
22
+ groupCommandMode: "owner",
23
+ groups: {},
24
+ richInteractions: false,
25
+ forwardThinking: true,
26
+ forwardToolCalls: false,
27
+ allowFrom: [],
28
+ reconnect: {
29
+ initialDelay: 1000,
30
+ maxDelay: 30000,
31
+ jitterRatio: 0.3,
32
+ maxRetries: Number.POSITIVE_INFINITY,
33
+ },
34
+ heartbeat: { interval: 25000, timeout: 10000 },
35
+ ack: { timeout: 10000, autoResendOnTimeout: false },
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ function buildSendEnvelope(
41
+ overrides: Partial<{
42
+ event: "message.send" | "message.reply";
43
+ text: string;
44
+ fragments: Array<Record<string, unknown>>;
45
+ chatType: "direct" | "group";
46
+ mentions: unknown[];
47
+ reply: unknown;
48
+ messageId: string;
49
+ chatId: string;
50
+ omitChatId: boolean;
51
+ senderId: string;
52
+ }> = {},
53
+ ): Envelope<MessagePayload> {
54
+ const chatId = overrides.chatId ?? "chat-1";
55
+ const chatType = overrides.chatType ?? "direct";
56
+ return {
57
+ version: "2",
58
+ event: overrides.event ?? "message.send",
59
+ trace_id: "trace-1",
60
+ emitted_at: 1776162600000,
61
+ ...(overrides.omitChatId ? {} : { chat_id: chatId }),
62
+ chat_type: chatType,
63
+ to: { id: chatId, type: chatType },
64
+ sender: {
65
+ id: overrides.senderId ?? "user-1",
66
+ type: "direct",
67
+ nick_name: "User One",
68
+ },
69
+ payload: {
70
+ message_id: overrides.messageId ?? "msg-1",
71
+ message_mode: "normal",
72
+ message: {
73
+ body: {
74
+ fragments: overrides.fragments ?? [{ kind: "text", text: overrides.text ?? "hello from user" }],
75
+ },
76
+ context: {
77
+ mentions: overrides.mentions ?? [],
78
+ reply: (overrides.reply ?? null) as never,
79
+ },
80
+ streaming: {
81
+ status: "static",
82
+ sequence: 0,
83
+ mutation_policy: "sealed",
84
+ started_at: null,
85
+ completed_at: null,
86
+ },
87
+ } as MessagePayload["message"],
88
+ },
89
+ } as Envelope<MessagePayload>;
90
+ }
91
+
92
+ function buildStreamEnvelope(
93
+ overrides: Partial<{
94
+ event: "message.created" | "message.add" | "message.done" | "message.failed";
95
+ fragments: Array<Record<string, unknown>>;
96
+ messageId: string;
97
+ sequence: number;
98
+ chatId: string;
99
+ chatType: "direct" | "group";
100
+ }> = {},
101
+ ): Envelope<unknown> {
102
+ const event = overrides.event ?? "message.done";
103
+ const sequence = overrides.sequence ?? 0;
104
+ const payload: Record<string, unknown> = {
105
+ message_id: overrides.messageId ?? "stream-1",
106
+ };
107
+ if (event === "message.add") {
108
+ payload.sequence = sequence;
109
+ payload.mutation = { type: "append", target_fragment_index: null };
110
+ payload.fragments = overrides.fragments ?? [{ kind: "text", text: "Hel", delta: "Hel" }];
111
+ payload.streaming = {
112
+ status: "streaming",
113
+ sequence,
114
+ mutation_policy: "append_text_only",
115
+ started_at: null,
116
+ completed_at: null,
117
+ };
118
+ payload.added_at = 1776162600001;
119
+ }
120
+ if (event === "message.done" || event === "message.failed") {
121
+ payload.fragments = overrides.fragments ?? [{ kind: "text", text: "Hello" }];
122
+ payload.streaming = {
123
+ status: event === "message.done" ? "done" : "failed",
124
+ sequence,
125
+ mutation_policy: "append_text_only",
126
+ started_at: null,
127
+ completed_at: 1776162600002,
128
+ };
129
+ payload.completed_at = 1776162600002;
130
+ }
131
+ return {
132
+ version: "2",
133
+ event,
134
+ trace_id: `trace-${event}`,
135
+ emitted_at: 1776162600000,
136
+ chat_id: overrides.chatId ?? "chat-1",
137
+ chat_type: overrides.chatType ?? "direct",
138
+ sender: { id: "user-1", type: "direct", nick_name: "User One" },
139
+ payload,
140
+ } as Envelope<unknown>;
141
+ }
142
+
143
+ describe("clawchat-plugin-openclaw inbound", () => {
144
+ it("dispatches a plain text message and flattens the body", async () => {
145
+ const ingest = vi.fn().mockResolvedValue(undefined);
146
+ await dispatchOpenclawClawlingInbound({
147
+ envelope: buildSendEnvelope({ text: "hello there" }),
148
+ cfg: {},
149
+ runtime: {} as never,
150
+ account: baseAccount({ groupMode: "mention" }),
151
+ ingest,
152
+ });
153
+ expect(ingest).toHaveBeenCalledTimes(1);
154
+ const call = ingest.mock.calls[0]![0];
155
+ expect(call.channel).toBe("clawchat-plugin-openclaw");
156
+ expect(call.rawBody).toBe("hello there");
157
+ expect(call.peer).toEqual({ kind: "direct", id: "chat-1" });
158
+ expect(call.messageId).toBe("msg-1");
159
+ });
160
+
161
+ it("skips self-echo messages from the connected agent user", async () => {
162
+ const ingest = vi.fn().mockResolvedValue(undefined);
163
+ const log = { info: vi.fn(), error: vi.fn() };
164
+
165
+ await dispatchOpenclawClawlingInbound({
166
+ envelope: buildSendEnvelope({ senderId: "agent-1", text: "my own echo" }),
167
+ cfg: {},
168
+ runtime: {} as never,
169
+ account: baseAccount({ userId: "agent-1" }),
170
+ ingest,
171
+ log,
172
+ });
173
+
174
+ expect(ingest).not.toHaveBeenCalled();
175
+ expect(log.info).toHaveBeenCalledWith(expect.stringContaining("self-echo"));
176
+ });
177
+
178
+ it("dispatches materialized message.send and message.reply inbound", async () => {
179
+ for (const event of ["message.send", "message.reply"] as const) {
180
+ const ingest = vi.fn().mockResolvedValue(undefined);
181
+ await dispatchOpenclawClawlingInbound({
182
+ envelope: buildSendEnvelope({ event, messageId: `msg-${event}` }),
183
+ cfg: {},
184
+ runtime: {} as never,
185
+ account: baseAccount(),
186
+ ingest,
187
+ });
188
+ expect(ingest).toHaveBeenCalledTimes(1);
189
+ }
190
+ });
191
+
192
+ it("does not dispatch stream lifecycle frames", async () => {
193
+ for (const event of ["message.created", "message.add", "message.done", "message.failed"] as const) {
194
+ const ingest = vi.fn().mockResolvedValue(undefined);
195
+ await dispatchOpenclawClawlingInbound({
196
+ envelope: buildStreamEnvelope({ event, messageId: `msg-${event}` }) as Envelope<MessagePayload>,
197
+ cfg: {},
198
+ runtime: {} as never,
199
+ account: baseAccount(),
200
+ ingest,
201
+ });
202
+ expect(ingest).not.toHaveBeenCalled();
203
+ }
204
+ });
205
+
206
+ it("marks wasMentioned=true when direct chat", async () => {
207
+ const ingest = vi.fn().mockResolvedValue(undefined);
208
+ await dispatchOpenclawClawlingInbound({
209
+ envelope: buildSendEnvelope({ chatType: "direct" }),
210
+ cfg: {},
211
+ runtime: {} as never,
212
+ account: baseAccount({ groupMode: "mention" }),
213
+ ingest,
214
+ });
215
+ const { wasMentioned } = ingest.mock.calls[0]![0];
216
+ expect(wasMentioned).toBe(true);
217
+ });
218
+
219
+ it("detectMention returns true when context.mentions contains userId (forward-compat for group chat)", async () => {
220
+ const { detectMention } = await import("./inbound.ts");
221
+ expect(
222
+ detectMention({
223
+ mentions: ["agent-1"],
224
+ chatType: "group",
225
+ userId: "agent-1",
226
+ }),
227
+ ).toBe(true);
228
+ });
229
+
230
+ it("detects object-shaped context mentions in group mention mode", async () => {
231
+ const ingest = vi.fn().mockResolvedValue(undefined);
232
+ await dispatchOpenclawClawlingInbound({
233
+ envelope: buildSendEnvelope({
234
+ chatType: "group",
235
+ mentions: [{ user_id: "agent-1", display: "@bot" }],
236
+ }),
237
+ cfg: {},
238
+ runtime: {} as never,
239
+ account: baseAccount({ groupMode: "mention" }),
240
+ ingest,
241
+ });
242
+
243
+ expect(ingest).toHaveBeenCalledTimes(1);
244
+ expect(ingest.mock.calls[0]![0].wasMentioned).toBe(true);
245
+ });
246
+
247
+ it("preserves mentioned user ids when groupMode all mentions another user", async () => {
248
+ const ingest = vi.fn().mockResolvedValue(undefined);
249
+ await dispatchOpenclawClawlingInbound({
250
+ envelope: buildSendEnvelope({
251
+ chatType: "group",
252
+ mentions: ["other-user"],
253
+ }),
254
+ cfg: {},
255
+ runtime: {} as never,
256
+ account: baseAccount({ groupMode: "all" }),
257
+ ingest,
258
+ });
259
+
260
+ expect(ingest).toHaveBeenCalledTimes(1);
261
+ expect(ingest.mock.calls[0]![0].wasMentioned).toBe(false);
262
+ expect(ingest.mock.calls[0]![0].mentionedUserIds).toEqual(["other-user"]);
263
+ });
264
+
265
+ it("preserves mention display labels and ordered mention text", async () => {
266
+ const ingest = vi.fn().mockResolvedValue(undefined);
267
+ await dispatchOpenclawClawlingInbound({
268
+ envelope: buildSendEnvelope({
269
+ chatType: "group",
270
+ fragments: [
271
+ { kind: "text", text: "请 " },
272
+ { kind: "mention", user_id: "usr_bob_456", display: "Bob" },
273
+ { kind: "text", text: " 和 " },
274
+ { kind: "mention", user_id: "usr_xiaopang_789", display: "小胖" },
275
+ { kind: "text", text: " 下午处理" },
276
+ ],
277
+ mentions: [
278
+ { user_id: "usr_bob_456", display: "Bob" },
279
+ { user_id: "usr_xiaopang_789", display: "小胖" },
280
+ ],
281
+ }),
282
+ cfg: {},
283
+ runtime: {} as never,
284
+ account: baseAccount({ groupMode: "all" }),
285
+ ingest,
286
+ });
287
+
288
+ expect(ingest).toHaveBeenCalledTimes(1);
289
+ expect(ingest.mock.calls[0]![0].rawBody).toBe("请 @Bob 和 @小胖 下午处理");
290
+ expect(ingest.mock.calls[0]![0].mentionedUsers).toEqual([
291
+ { id: "usr_bob_456", display: "Bob" },
292
+ { id: "usr_xiaopang_789", display: "小胖" },
293
+ ]);
294
+ });
295
+
296
+ it("detectMention returns false for group chat when userId not mentioned", async () => {
297
+ const { detectMention } = await import("./inbound.ts");
298
+ expect(
299
+ detectMention({
300
+ mentions: ["user-2"],
301
+ chatType: "group",
302
+ userId: "agent-1",
303
+ }),
304
+ ).toBe(false);
305
+ });
306
+
307
+ it("skips unmentioned group messages in mention mode", async () => {
308
+ const ingest = vi.fn().mockResolvedValue(undefined);
309
+ await dispatchOpenclawClawlingInbound({
310
+ envelope: buildSendEnvelope({ chatType: "group" }),
311
+ cfg: {},
312
+ runtime: {} as never,
313
+ account: baseAccount({ groupMode: "mention" }),
314
+ ingest,
315
+ });
316
+ expect(ingest).not.toHaveBeenCalled();
317
+ });
318
+
319
+ it("uses exact per-group mention mode for matching group chat_id", async () => {
320
+ const ingest = vi.fn().mockResolvedValue(undefined);
321
+ await dispatchOpenclawClawlingInbound({
322
+ envelope: buildSendEnvelope({ chatType: "group", chatId: "group-quiet" }),
323
+ cfg: {},
324
+ runtime: {} as never,
325
+ account: baseAccount({
326
+ groupMode: "all",
327
+ groups: { "group-quiet": { groupMode: "mention", groupCommandMode: "owner" } },
328
+ }),
329
+ ingest,
330
+ });
331
+
332
+ expect(ingest).not.toHaveBeenCalled();
333
+ });
334
+
335
+ it("uses wildcard per-group mention mode when exact group is absent", async () => {
336
+ const ingest = vi.fn().mockResolvedValue(undefined);
337
+ await dispatchOpenclawClawlingInbound({
338
+ envelope: buildSendEnvelope({ chatType: "group", chatId: "group-any" }),
339
+ cfg: {},
340
+ runtime: {} as never,
341
+ account: baseAccount({
342
+ groupMode: "all",
343
+ groups: { "*": { groupMode: "mention", groupCommandMode: "owner" } },
344
+ }),
345
+ ingest,
346
+ });
347
+
348
+ expect(ingest).not.toHaveBeenCalled();
349
+ });
350
+
351
+ it("lets exact per-group all mode override wildcard mention mode", async () => {
352
+ const ingest = vi.fn().mockResolvedValue(undefined);
353
+ await dispatchOpenclawClawlingInbound({
354
+ envelope: buildSendEnvelope({ chatType: "group", chatId: "group-open" }),
355
+ cfg: {},
356
+ runtime: {} as never,
357
+ account: baseAccount({
358
+ groupMode: "mention",
359
+ groups: {
360
+ "group-open": { groupMode: "all", groupCommandMode: "owner" },
361
+ "*": { groupMode: "mention", groupCommandMode: "owner" },
362
+ },
363
+ }),
364
+ ingest,
365
+ });
366
+
367
+ expect(ingest).toHaveBeenCalledTimes(1);
368
+ });
369
+
370
+ it("skips messages with empty renderable text", async () => {
371
+ const ingest = vi.fn().mockResolvedValue(undefined);
372
+ await dispatchOpenclawClawlingInbound({
373
+ envelope: buildSendEnvelope({ text: " " }),
374
+ cfg: {},
375
+ runtime: {} as never,
376
+ account: baseAccount(),
377
+ ingest,
378
+ });
379
+ expect(ingest).not.toHaveBeenCalled();
380
+ });
381
+
382
+ it("extracts replyCtx from message.reply envelopes", async () => {
383
+ const ingest = vi.fn().mockResolvedValue(undefined);
384
+ const replyRef = {
385
+ reply_to_msg_id: "m-orig",
386
+ reply_preview: {
387
+ id: "user-2",
388
+ nick_name: "User Two",
389
+ fragments: [{ kind: "text", text: "original text" }],
390
+ },
391
+ };
392
+ await dispatchOpenclawClawlingInbound({
393
+ envelope: buildSendEnvelope({ event: "message.reply", reply: replyRef, chatId: "chat-1" }),
394
+ cfg: {},
395
+ runtime: {} as never,
396
+ account: baseAccount(),
397
+ ingest,
398
+ });
399
+ const { replyCtx } = ingest.mock.calls[0]![0];
400
+ expect(replyCtx).toEqual({
401
+ replyToMessageId: "m-orig",
402
+ replyPreviewChatId: "chat-1",
403
+ replyPreviewSenderId: "user-2",
404
+ replyPreviewNickName: "User Two",
405
+ replyPreviewText: "original text",
406
+ });
407
+ });
408
+
409
+ it("does not own duplicate suppression when the same message_id is parsed twice", async () => {
410
+ const ingest = vi.fn().mockResolvedValue(undefined);
411
+ const env = buildSendEnvelope({ messageId: "dup-1" });
412
+ await dispatchOpenclawClawlingInbound({
413
+ envelope: env,
414
+ cfg: {},
415
+ runtime: {} as never,
416
+ account: baseAccount(),
417
+ ingest,
418
+ });
419
+ await dispatchOpenclawClawlingInbound({
420
+ envelope: env,
421
+ cfg: {},
422
+ runtime: {} as never,
423
+ account: baseAccount(),
424
+ ingest,
425
+ });
426
+ expect(ingest).toHaveBeenCalledTimes(2);
427
+ });
428
+
429
+ it("passes mediaItems extracted from body fragments to ingest", async () => {
430
+ const ingest = vi.fn().mockResolvedValue(undefined);
431
+ const env = buildSendEnvelope({});
432
+ // Replace the body's fragments with text + image; the local fragment union accepts these directly.
433
+ env.payload.message.body.fragments = [
434
+ { kind: "text", text: "hello" },
435
+ { kind: "image", url: "https://cdn/x.png", mime: "image/png" },
436
+ ];
437
+ await dispatchOpenclawClawlingInbound({
438
+ envelope: env,
439
+ cfg: {} as never,
440
+ runtime: {} as never,
441
+ account: baseAccount(),
442
+ ingest,
443
+ });
444
+ const call = ingest.mock.calls[0]![0];
445
+ expect(call.mediaItems).toEqual([
446
+ { kind: "image", url: "https://cdn/x.png", mime: "image/png" },
447
+ ]);
448
+ });
449
+
450
+ it("dispatches image-only messages", async () => {
451
+ const ingest = vi.fn().mockResolvedValue(undefined);
452
+ const env = buildSendEnvelope({ text: " " });
453
+ env.payload.message.body.fragments = [{ kind: "image", url: "https://cdn/x.png" }];
454
+ await dispatchOpenclawClawlingInbound({
455
+ envelope: env,
456
+ cfg: {} as never,
457
+ runtime: {} as never,
458
+ account: baseAccount(),
459
+ ingest,
460
+ });
461
+ expect(ingest).toHaveBeenCalledTimes(1);
462
+ const call = ingest.mock.calls[0]![0];
463
+ expect(call.rawBody).toBe("![image](https://cdn/x.png)");
464
+ expect(call.mediaItems).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
465
+ });
466
+
467
+ it("dispatches with canonical envelope.sender shape", async () => {
468
+ const ingest = vi.fn().mockResolvedValue(undefined);
469
+ const env = buildSendEnvelope({ text: "hello" });
470
+ await dispatchOpenclawClawlingInbound({
471
+ envelope: env,
472
+ cfg: {} as never,
473
+ runtime: {} as never,
474
+ account: baseAccount(),
475
+ ingest,
476
+ });
477
+ expect(ingest).toHaveBeenCalledTimes(1);
478
+ const call = ingest.mock.calls[0]![0];
479
+ expect(call.senderId).toBe("user-1");
480
+ expect(call.senderNickName).toBe("User One");
481
+ expect(call.peer).toEqual({ kind: "direct", id: "chat-1" });
482
+ });
483
+
484
+ it("skips business events without required chat_id", async () => {
485
+ const ingest = vi.fn().mockResolvedValue(undefined);
486
+ await dispatchOpenclawClawlingInbound({
487
+ envelope: buildSendEnvelope({ omitChatId: true }),
488
+ cfg: {} as never,
489
+ runtime: {} as never,
490
+ account: baseAccount(),
491
+ ingest,
492
+ });
493
+ expect(ingest).not.toHaveBeenCalled();
494
+ });
495
+
496
+ it("ingest receives mediaItems = [] when body has only text", async () => {
497
+ const ingest = vi.fn().mockResolvedValue(undefined);
498
+ await dispatchOpenclawClawlingInbound({
499
+ envelope: buildSendEnvelope({ text: "hi" }),
500
+ cfg: {} as never,
501
+ runtime: {} as never,
502
+ account: baseAccount(),
503
+ ingest,
504
+ });
505
+ const call = ingest.mock.calls[0]![0];
506
+ expect(call.mediaItems).toEqual([]);
507
+ });
508
+ });