320 lines
8.3 KiB
TypeScript
320 lines
8.3 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
|
import { sanitizeTerminalText } from "../../../../src/terminal/safe-text.js";
|
|
import {
|
|
describeIMessageEchoDropLog,
|
|
resolveIMessageInboundDecision,
|
|
} from "./inbound-processing.js";
|
|
import { createSelfChatCache } from "./self-chat-cache.js";
|
|
|
|
describe("resolveIMessageInboundDecision echo detection", () => {
|
|
const cfg = {} as OpenClawConfig;
|
|
type InboundDecisionParams = Parameters<typeof resolveIMessageInboundDecision>[0];
|
|
|
|
function createInboundDecisionParams(
|
|
overrides: Omit<Partial<InboundDecisionParams>, "message"> & {
|
|
message?: Partial<InboundDecisionParams["message"]>;
|
|
} = {},
|
|
): InboundDecisionParams {
|
|
const { message: messageOverrides, ...restOverrides } = overrides;
|
|
const message = {
|
|
id: 42,
|
|
sender: "+15555550123",
|
|
text: "ok",
|
|
is_from_me: false,
|
|
is_group: false,
|
|
...messageOverrides,
|
|
};
|
|
const messageText = restOverrides.messageText ?? message.text ?? "";
|
|
const bodyText = restOverrides.bodyText ?? messageText;
|
|
const baseParams: Omit<InboundDecisionParams, "message" | "messageText" | "bodyText"> = {
|
|
cfg,
|
|
accountId: "default",
|
|
opts: undefined,
|
|
allowFrom: [],
|
|
groupAllowFrom: [],
|
|
groupPolicy: "open",
|
|
dmPolicy: "open",
|
|
storeAllowFrom: [],
|
|
historyLimit: 0,
|
|
groupHistories: new Map(),
|
|
echoCache: undefined,
|
|
selfChatCache: undefined,
|
|
logVerbose: undefined,
|
|
};
|
|
return {
|
|
...baseParams,
|
|
...restOverrides,
|
|
message,
|
|
messageText,
|
|
bodyText,
|
|
};
|
|
}
|
|
|
|
function resolveDecision(
|
|
overrides: Omit<Partial<InboundDecisionParams>, "message"> & {
|
|
message?: Partial<InboundDecisionParams["message"]>;
|
|
} = {},
|
|
) {
|
|
return resolveIMessageInboundDecision(createInboundDecisionParams(overrides));
|
|
}
|
|
|
|
it("drops inbound messages when outbound message id matches echo cache", () => {
|
|
const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => {
|
|
return lookup.messageId === "42";
|
|
});
|
|
|
|
const decision = resolveDecision({
|
|
message: {
|
|
id: 42,
|
|
text: "Reasoning:\n_step_",
|
|
},
|
|
messageText: "Reasoning:\n_step_",
|
|
bodyText: "Reasoning:\n_step_",
|
|
echoCache: { has: echoHas },
|
|
});
|
|
|
|
expect(decision).toEqual({ kind: "drop", reason: "echo" });
|
|
expect(echoHas).toHaveBeenCalledWith(
|
|
"default:imessage:+15555550123",
|
|
expect.objectContaining({
|
|
text: "Reasoning:\n_step_",
|
|
messageId: "42",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("drops reflected self-chat duplicates after seeing the from-me copy", () => {
|
|
const selfChatCache = createSelfChatCache();
|
|
const createdAt = "2026-03-02T20:58:10.649Z";
|
|
|
|
expect(
|
|
resolveDecision({
|
|
message: {
|
|
id: 9641,
|
|
text: "Do you want to report this issue?",
|
|
created_at: createdAt,
|
|
is_from_me: true,
|
|
},
|
|
messageText: "Do you want to report this issue?",
|
|
bodyText: "Do you want to report this issue?",
|
|
selfChatCache,
|
|
}),
|
|
).toEqual({ kind: "drop", reason: "from me" });
|
|
|
|
expect(
|
|
resolveDecision({
|
|
message: {
|
|
id: 9642,
|
|
text: "Do you want to report this issue?",
|
|
created_at: createdAt,
|
|
},
|
|
messageText: "Do you want to report this issue?",
|
|
bodyText: "Do you want to report this issue?",
|
|
selfChatCache,
|
|
}),
|
|
).toEqual({ kind: "drop", reason: "self-chat echo" });
|
|
});
|
|
|
|
it("does not drop same-text messages when created_at differs", () => {
|
|
const selfChatCache = createSelfChatCache();
|
|
|
|
resolveDecision({
|
|
message: {
|
|
id: 9641,
|
|
text: "ok",
|
|
created_at: "2026-03-02T20:58:10.649Z",
|
|
is_from_me: true,
|
|
},
|
|
selfChatCache,
|
|
});
|
|
|
|
const decision = resolveDecision({
|
|
message: {
|
|
id: 9642,
|
|
text: "ok",
|
|
created_at: "2026-03-02T20:58:11.649Z",
|
|
},
|
|
selfChatCache,
|
|
});
|
|
|
|
expect(decision.kind).toBe("dispatch");
|
|
});
|
|
|
|
it("keeps self-chat cache scoped to configured group threads", () => {
|
|
const selfChatCache = createSelfChatCache();
|
|
const groupedCfg = {
|
|
channels: {
|
|
imessage: {
|
|
groups: {
|
|
"123": {},
|
|
"456": {},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const createdAt = "2026-03-02T20:58:10.649Z";
|
|
|
|
expect(
|
|
resolveDecision({
|
|
cfg: groupedCfg,
|
|
message: {
|
|
id: 9701,
|
|
chat_id: 123,
|
|
text: "same text",
|
|
created_at: createdAt,
|
|
is_from_me: true,
|
|
},
|
|
selfChatCache,
|
|
}),
|
|
).toEqual({ kind: "drop", reason: "from me" });
|
|
|
|
const decision = resolveDecision({
|
|
cfg: groupedCfg,
|
|
message: {
|
|
id: 9702,
|
|
chat_id: 456,
|
|
text: "same text",
|
|
created_at: createdAt,
|
|
},
|
|
selfChatCache,
|
|
});
|
|
|
|
expect(decision.kind).toBe("dispatch");
|
|
});
|
|
|
|
it("does not drop other participants in the same group thread", () => {
|
|
const selfChatCache = createSelfChatCache();
|
|
const createdAt = "2026-03-02T20:58:10.649Z";
|
|
|
|
expect(
|
|
resolveDecision({
|
|
message: {
|
|
id: 9751,
|
|
chat_id: 123,
|
|
text: "same text",
|
|
created_at: createdAt,
|
|
is_from_me: true,
|
|
is_group: true,
|
|
},
|
|
selfChatCache,
|
|
}),
|
|
).toEqual({ kind: "drop", reason: "from me" });
|
|
|
|
const decision = resolveDecision({
|
|
message: {
|
|
id: 9752,
|
|
chat_id: 123,
|
|
sender: "+15555550999",
|
|
text: "same text",
|
|
created_at: createdAt,
|
|
is_group: true,
|
|
},
|
|
selfChatCache,
|
|
});
|
|
|
|
expect(decision.kind).toBe("dispatch");
|
|
});
|
|
|
|
it("sanitizes reflected duplicate previews before logging", () => {
|
|
const selfChatCache = createSelfChatCache();
|
|
const logVerbose = vi.fn();
|
|
const createdAt = "2026-03-02T20:58:10.649Z";
|
|
const bodyText = "line-1\nline-2\t\u001b[31mred";
|
|
|
|
resolveDecision({
|
|
message: {
|
|
id: 9801,
|
|
text: bodyText,
|
|
created_at: createdAt,
|
|
is_from_me: true,
|
|
},
|
|
messageText: bodyText,
|
|
bodyText,
|
|
selfChatCache,
|
|
logVerbose,
|
|
});
|
|
|
|
resolveDecision({
|
|
message: {
|
|
id: 9802,
|
|
text: bodyText,
|
|
created_at: createdAt,
|
|
},
|
|
messageText: bodyText,
|
|
bodyText,
|
|
selfChatCache,
|
|
logVerbose,
|
|
});
|
|
|
|
expect(logVerbose).toHaveBeenCalledWith(
|
|
`imessage: dropping self-chat reflected duplicate: "${sanitizeTerminalText(bodyText)}"`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("describeIMessageEchoDropLog", () => {
|
|
it("includes message id when available", () => {
|
|
expect(
|
|
describeIMessageEchoDropLog({
|
|
messageText: "Reasoning:\n_step_",
|
|
messageId: "abc-123",
|
|
}),
|
|
).toContain("id=abc-123");
|
|
});
|
|
});
|
|
|
|
describe("resolveIMessageInboundDecision command auth", () => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const resolveDmCommandDecision = (params: { messageId: number; storeAllowFrom: string[] }) =>
|
|
resolveIMessageInboundDecision({
|
|
cfg,
|
|
accountId: "default",
|
|
message: {
|
|
id: params.messageId,
|
|
sender: "+15555550123",
|
|
text: "/status",
|
|
is_from_me: false,
|
|
is_group: false,
|
|
},
|
|
opts: undefined,
|
|
messageText: "/status",
|
|
bodyText: "/status",
|
|
allowFrom: [],
|
|
groupAllowFrom: [],
|
|
groupPolicy: "open",
|
|
dmPolicy: "open",
|
|
storeAllowFrom: params.storeAllowFrom,
|
|
historyLimit: 0,
|
|
groupHistories: new Map(),
|
|
echoCache: undefined,
|
|
logVerbose: undefined,
|
|
});
|
|
|
|
it("does not auto-authorize DM commands in open mode without allowlists", () => {
|
|
const decision = resolveDmCommandDecision({
|
|
messageId: 100,
|
|
storeAllowFrom: [],
|
|
});
|
|
|
|
expect(decision.kind).toBe("dispatch");
|
|
if (decision.kind !== "dispatch") {
|
|
return;
|
|
}
|
|
expect(decision.commandAuthorized).toBe(false);
|
|
});
|
|
|
|
it("authorizes DM commands for senders in pairing-store allowlist", () => {
|
|
const decision = resolveDmCommandDecision({
|
|
messageId: 101,
|
|
storeAllowFrom: ["+15555550123"],
|
|
});
|
|
|
|
expect(decision.kind).toBe("dispatch");
|
|
if (decision.kind !== "dispatch") {
|
|
return;
|
|
}
|
|
expect(decision.commandAuthorized).toBe(true);
|
|
});
|
|
});
|