@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.
- package/README.md +39 -17
- package/dist/index.js +3 -1
- package/dist/src/api-client.js +71 -12
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/channel.js +5 -5
- package/dist/src/channel.setup.js +4 -17
- package/dist/src/clawchat-memory.js +290 -0
- package/dist/src/clawchat-metadata.js +235 -0
- package/dist/src/client.js +31 -93
- package/dist/src/commands.js +3 -3
- package/dist/src/config.js +58 -3
- package/dist/src/group-message-coalescer.js +107 -0
- package/dist/src/inbound.js +24 -28
- package/dist/src/login.runtime.js +82 -19
- package/dist/src/media-runtime.js +2 -3
- package/dist/src/message-mapper.js +1 -1
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +281 -56
- package/dist/src/plugin-prompts.js +76 -0
- package/dist/src/profile-prompt.js +150 -0
- package/dist/src/profile-sync.js +169 -0
- package/dist/src/prompt-injection.js +25 -0
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +2 -2
- package/dist/src/reply-dispatcher.js +143 -40
- package/dist/src/runtime.js +813 -109
- package/dist/src/storage.js +636 -0
- package/dist/src/tools-schema.js +70 -10
- package/dist/src/tools.js +600 -112
- package/dist/src/ws-alignment.js +8 -0
- package/dist/src/ws-client.js +588 -0
- package/index.ts +6 -1
- package/openclaw.plugin.json +44 -4
- package/package.json +4 -3
- package/prompts/platform.md +7 -0
- package/skills/clawchat/SKILL.md +90 -0
- package/src/api-client.test.ts +360 -15
- package/src/api-client.ts +127 -25
- package/src/api-types.test-d.ts +12 -0
- package/src/api-types.ts +71 -4
- package/src/buffered-stream.test.ts +1 -1
- package/src/buffered-stream.ts +1 -1
- package/src/channel.outbound.test.ts +270 -60
- package/src/channel.setup.ts +9 -18
- package/src/channel.test.ts +33 -25
- package/src/channel.ts +5 -7
- package/src/clawchat-memory.test.ts +372 -0
- package/src/clawchat-memory.ts +363 -0
- package/src/clawchat-metadata.test.ts +350 -0
- package/src/clawchat-metadata.ts +352 -0
- package/src/client.test.ts +57 -48
- package/src/client.ts +37 -129
- package/src/commands.test.ts +2 -2
- package/src/commands.ts +3 -3
- package/src/config.test.ts +169 -4
- package/src/config.ts +86 -6
- package/src/group-message-coalescer.test.ts +223 -0
- package/src/group-message-coalescer.ts +154 -0
- package/src/inbound.test.ts +106 -19
- package/src/inbound.ts +31 -35
- package/src/login.runtime.test.ts +294 -11
- package/src/login.runtime.ts +90 -21
- package/src/manifest.test.ts +86 -14
- package/src/media-runtime.test.ts +31 -2
- package/src/media-runtime.ts +7 -10
- package/src/message-mapper.test.ts +2 -2
- package/src/message-mapper.ts +2 -2
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +811 -95
- package/src/outbound.ts +332 -65
- package/src/plugin-entry.test.ts +3 -1
- package/src/plugin-prompts.test.ts +78 -0
- package/src/plugin-prompts.ts +92 -0
- package/src/profile-prompt.test.ts +435 -0
- package/src/profile-prompt.ts +208 -0
- package/src/profile-sync.test.ts +611 -0
- package/src/profile-sync.ts +268 -0
- package/src/prompt-injection.test.ts +39 -0
- package/src/prompt-injection.ts +45 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.ts +2 -2
- package/src/reply-dispatcher.test.ts +720 -135
- package/src/reply-dispatcher.ts +174 -42
- package/src/runtime.test.ts +3884 -337
- package/src/runtime.ts +956 -128
- package/src/storage.test.ts +692 -0
- package/src/storage.ts +989 -0
- package/src/streaming.test.ts +1 -1
- package/src/streaming.ts +1 -1
- package/src/tools-schema.ts +115 -13
- package/src/tools.test.ts +501 -10
- package/src/tools.ts +739 -133
- package/src/ws-alignment.ts +9 -0
- package/src/ws-client.test.ts +1218 -0
- 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
|
+
}
|
package/src/inbound.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Envelope,
|
|
1
|
+
import type { Envelope, MessagePayload } from "./protocol-types.ts";
|
|
2
2
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
3
|
-
import {
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
5
|
-
import { dispatchOpenclawClawlingInbound
|
|
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:
|
|
46
|
+
mentions: unknown[];
|
|
42
47
|
reply: unknown;
|
|
43
48
|
messageId: string;
|
|
44
49
|
chatId: string;
|
|
45
50
|
omitChatId: boolean;
|
|
46
51
|
}> = {},
|
|
47
|
-
): Envelope<
|
|
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
|
|
86
|
+
} as MessagePayload["message"],
|
|
82
87
|
},
|
|
83
|
-
} as Envelope<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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("
|
|
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(
|
|
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
|
|
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 "
|
|
5
|
+
} from "./protocol-types.ts";
|
|
6
6
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
7
|
-
import type
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
204
|
+
mentionFallbackIds: mentionIds,
|
|
212
205
|
});
|
|
213
206
|
const mediaItems = extractMediaFragments(message.body.fragments as never);
|
|
214
207
|
const wasMentioned = detectMention({
|
|
215
|
-
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
|
-
|
|
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,
|