mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-22 22:52:03 +00:00
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { ResolvedIMessageAccount } from "./accounts.js";
|
|
import { imessagePlugin } from "./channel.js";
|
|
import type { IMessageRpcClient } from "./client.js";
|
|
import { imessageOutbound } from "./outbound-adapter.js";
|
|
import { sendMessageIMessage } from "./send.js";
|
|
|
|
function requireIMessageSendText() {
|
|
const sendText = imessagePlugin.outbound?.sendText;
|
|
if (!sendText) {
|
|
throw new Error("imessage outbound.sendText unavailable");
|
|
}
|
|
return sendText;
|
|
}
|
|
|
|
function requireIMessageSendMedia() {
|
|
const sendMedia = imessagePlugin.outbound?.sendMedia;
|
|
if (!sendMedia) {
|
|
throw new Error("imessage outbound.sendMedia unavailable");
|
|
}
|
|
return sendMedia;
|
|
}
|
|
|
|
function requireIMessageChunker() {
|
|
const chunker = imessagePlugin.outbound?.chunker;
|
|
if (!chunker) {
|
|
throw new Error("imessage outbound.chunker unavailable");
|
|
}
|
|
return chunker;
|
|
}
|
|
|
|
const requestMock = vi.fn();
|
|
const stopMock = vi.fn();
|
|
|
|
const defaultAccount: ResolvedIMessageAccount = {
|
|
accountId: "default",
|
|
enabled: true,
|
|
configured: false,
|
|
config: {},
|
|
};
|
|
|
|
function createClient(): IMessageRpcClient {
|
|
return {
|
|
request: (...args: unknown[]) => requestMock(...args),
|
|
stop: (...args: unknown[]) => stopMock(...args),
|
|
} as unknown as IMessageRpcClient;
|
|
}
|
|
|
|
async function sendWithDefaults(
|
|
to: string,
|
|
text: string,
|
|
opts: Parameters<typeof sendMessageIMessage>[2] = {},
|
|
) {
|
|
return await sendMessageIMessage(to, text, {
|
|
account: defaultAccount,
|
|
config: {},
|
|
client: createClient(),
|
|
...opts,
|
|
});
|
|
}
|
|
|
|
function getSentParams() {
|
|
return requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
|
}
|
|
|
|
async function expectDirectOutboundResult(params: {
|
|
invoke: () => Promise<{ channel: string; messageId: string }>;
|
|
sendIMessage: ReturnType<typeof vi.fn>;
|
|
to: string;
|
|
text: string;
|
|
expectedOptions: Record<string, unknown>;
|
|
expectedResult: { channel: string; messageId: string };
|
|
}) {
|
|
const result = await params.invoke();
|
|
expect(params.sendIMessage).toHaveBeenCalledWith(
|
|
params.to,
|
|
params.text,
|
|
expect.objectContaining(params.expectedOptions),
|
|
);
|
|
expect(result).toEqual(params.expectedResult);
|
|
}
|
|
|
|
async function expectReplyToTextForwarding(params: {
|
|
invoke: () => Promise<{ channel: string; messageId: string }>;
|
|
sendIMessage: ReturnType<typeof vi.fn>;
|
|
}) {
|
|
await expectDirectOutboundResult({
|
|
invoke: params.invoke,
|
|
sendIMessage: params.sendIMessage,
|
|
to: "chat_id:12",
|
|
text: "hello",
|
|
expectedOptions: {
|
|
accountId: "default",
|
|
replyToId: "reply-1",
|
|
maxBytes: 3 * 1024 * 1024,
|
|
},
|
|
expectedResult: { channel: "imessage", messageId: "m-text" },
|
|
});
|
|
}
|
|
|
|
async function expectMediaLocalRootsForwarding(params: {
|
|
invoke: () => Promise<{ channel: string; messageId: string }>;
|
|
sendIMessage: ReturnType<typeof vi.fn>;
|
|
}) {
|
|
await expectDirectOutboundResult({
|
|
invoke: params.invoke,
|
|
sendIMessage: params.sendIMessage,
|
|
to: "chat_id:88",
|
|
text: "caption",
|
|
expectedOptions: {
|
|
mediaUrl: "/tmp/workspace/pic.png",
|
|
mediaLocalRoots: ["/tmp/workspace"],
|
|
accountId: "acct-1",
|
|
replyToId: "reply-2",
|
|
maxBytes: 3 * 1024 * 1024,
|
|
},
|
|
expectedResult: { channel: "imessage", messageId: "m-media-local" },
|
|
});
|
|
}
|
|
|
|
describe("imessagePlugin outbound", () => {
|
|
const cfg = {
|
|
channels: {
|
|
imessage: {
|
|
mediaMaxMb: 3,
|
|
},
|
|
},
|
|
};
|
|
|
|
it("forwards replyToId on direct sendText adapter path", async () => {
|
|
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-text" });
|
|
const sendText = requireIMessageSendText();
|
|
|
|
await expectReplyToTextForwarding({
|
|
invoke: async () =>
|
|
await sendText({
|
|
cfg,
|
|
to: "chat_id:12",
|
|
text: "hello",
|
|
accountId: "default",
|
|
replyToId: "reply-1",
|
|
deps: { sendIMessage },
|
|
}),
|
|
sendIMessage,
|
|
});
|
|
});
|
|
|
|
it("forwards replyToId on direct sendMedia adapter path", async () => {
|
|
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-media" });
|
|
const sendMedia = requireIMessageSendMedia();
|
|
|
|
const result = await sendMedia({
|
|
cfg,
|
|
to: "chat_id:77",
|
|
text: "caption",
|
|
mediaUrl: "https://example.com/pic.png",
|
|
accountId: "acct-1",
|
|
replyToId: "reply-2",
|
|
deps: { sendIMessage },
|
|
});
|
|
|
|
expect(sendIMessage).toHaveBeenCalledWith(
|
|
"chat_id:77",
|
|
"caption",
|
|
expect.objectContaining({
|
|
mediaUrl: "https://example.com/pic.png",
|
|
accountId: "acct-1",
|
|
replyToId: "reply-2",
|
|
maxBytes: 3 * 1024 * 1024,
|
|
}),
|
|
);
|
|
expect(result).toEqual({ channel: "imessage", messageId: "m-media" });
|
|
});
|
|
|
|
it("forwards mediaLocalRoots on direct sendMedia adapter path", async () => {
|
|
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-media-local" });
|
|
const sendMedia = requireIMessageSendMedia();
|
|
const mediaLocalRoots = ["/tmp/workspace"];
|
|
|
|
await expectMediaLocalRootsForwarding({
|
|
invoke: async () =>
|
|
await sendMedia({
|
|
cfg,
|
|
to: "chat_id:88",
|
|
text: "caption",
|
|
mediaUrl: "/tmp/workspace/pic.png",
|
|
mediaLocalRoots,
|
|
accountId: "acct-1",
|
|
replyToId: "reply-2",
|
|
deps: { sendIMessage },
|
|
}),
|
|
sendIMessage,
|
|
});
|
|
});
|
|
|
|
it("chunks outbound text without requiring iMessage runtime initialization", () => {
|
|
const chunker = requireIMessageChunker();
|
|
|
|
expect(() => chunker("hello world", 5)).not.toThrow();
|
|
expect(chunker("hello world", 5)).toEqual(["hello", "world"]);
|
|
});
|
|
});
|
|
|
|
describe("imessageOutbound", () => {
|
|
const cfg = {
|
|
channels: {
|
|
imessage: {
|
|
mediaMaxMb: 3,
|
|
},
|
|
},
|
|
};
|
|
|
|
it("forwards replyToId on direct text sends", async () => {
|
|
const sendIMessage = vi.fn().mockResolvedValueOnce({ messageId: "m-text" });
|
|
|
|
await expectReplyToTextForwarding({
|
|
invoke: async () =>
|
|
await imessageOutbound.sendText!({
|
|
cfg,
|
|
to: "chat_id:12",
|
|
text: "hello",
|
|
accountId: "default",
|
|
replyToId: "reply-1",
|
|
deps: { sendIMessage },
|
|
}),
|
|
sendIMessage,
|
|
});
|
|
});
|
|
|
|
it("forwards mediaLocalRoots on direct media sends", async () => {
|
|
const sendIMessage = vi.fn().mockResolvedValueOnce({ messageId: "m-media-local" });
|
|
|
|
await expectMediaLocalRootsForwarding({
|
|
invoke: async () =>
|
|
await imessageOutbound.sendMedia!({
|
|
cfg,
|
|
to: "chat_id:88",
|
|
text: "caption",
|
|
mediaUrl: "/tmp/workspace/pic.png",
|
|
mediaLocalRoots: ["/tmp/workspace"],
|
|
accountId: "acct-1",
|
|
replyToId: "reply-2",
|
|
deps: { sendIMessage },
|
|
}),
|
|
sendIMessage,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("sendMessageIMessage", () => {
|
|
it("sends to chat_id targets", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:123", "hi");
|
|
const params = getSentParams();
|
|
expect(requestMock).toHaveBeenCalledWith("send", expect.any(Object), expect.any(Object));
|
|
expect(params.chat_id).toBe(123);
|
|
expect(params.text).toBe("hi");
|
|
});
|
|
|
|
it("applies sms service prefix", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("sms:+1555", "hello");
|
|
const params = getSentParams();
|
|
expect(params.service).toBe("sms");
|
|
expect(params.to).toBe("+1555");
|
|
});
|
|
|
|
it("adds file attachment with placeholder text", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:7", "", {
|
|
mediaUrl: "http://x/y.jpg",
|
|
resolveAttachmentImpl: async () => ({
|
|
path: "/tmp/imessage-media.jpg",
|
|
contentType: "image/jpeg",
|
|
}),
|
|
});
|
|
const params = getSentParams();
|
|
expect(params.file).toBe("/tmp/imessage-media.jpg");
|
|
expect(params.text).toBe("<media:image>");
|
|
});
|
|
|
|
it("normalizes mixed-case parameterized MIME for attachment placeholder text", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:7", "", {
|
|
mediaUrl: "http://x/voice",
|
|
resolveAttachmentImpl: async () => ({
|
|
path: "/tmp/imessage-media.ogg",
|
|
contentType: " Audio/Ogg; codecs=opus ",
|
|
}),
|
|
});
|
|
const params = getSentParams();
|
|
expect(params.file).toBe("/tmp/imessage-media.ogg");
|
|
expect(params.text).toBe("<media:audio>");
|
|
});
|
|
|
|
it("returns message id when rpc provides one", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true, id: 123 });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
const result = await sendWithDefaults("chat_id:7", "hello");
|
|
expect(result.messageId).toBe("123");
|
|
});
|
|
|
|
it("passes replyToId as separate reply_to param instead of embedding in text", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:123", "hello world", {
|
|
replyToId: "abc-123",
|
|
});
|
|
const params = getSentParams();
|
|
expect(params.text).toBe("hello world");
|
|
expect(params.reply_to).toBe("abc-123");
|
|
});
|
|
|
|
it("strips inline reply tags from text and passes replyToId as reply_to param", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:123", " [[reply_to:old-id]] hello", {
|
|
replyToId: "new-id",
|
|
});
|
|
const params = getSentParams();
|
|
expect(params.text).toBe("hello");
|
|
expect(params.reply_to).toBe("new-id");
|
|
});
|
|
|
|
it("sanitizes replyToId before passing as reply_to param", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:123", "hello", {
|
|
replyToId: " [ab]\n\u0000c\td ] ",
|
|
});
|
|
const params = getSentParams();
|
|
expect(params.text).toBe("hello");
|
|
expect(params.reply_to).toBe("abcd");
|
|
});
|
|
|
|
it("omits reply_to param when sanitized replyToId is empty", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:123", "hello", {
|
|
replyToId: "[]\u0000\n\r",
|
|
});
|
|
const params = getSentParams();
|
|
expect(params.text).toBe("hello");
|
|
expect(params.reply_to).toBeUndefined();
|
|
});
|
|
|
|
it("strips stray [[reply_to:...]] tags from text even without replyToId option", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:123", "[[reply_to:65]] Great question");
|
|
const params = getSentParams();
|
|
expect(params.text).toBe("Great question");
|
|
expect(params.reply_to).toBeUndefined();
|
|
});
|
|
|
|
it("strips [[audio_as_voice]] tags from outbound text", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:123", "hello [[audio_as_voice]] world");
|
|
const params = getSentParams();
|
|
expect(params.text).toBe("hello world");
|
|
});
|
|
|
|
it("throws when text is only directive tags and no media", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await expect(sendWithDefaults("chat_id:123", "[[reply_to:65]]")).rejects.toThrow(
|
|
"iMessage send requires text or media",
|
|
);
|
|
});
|
|
|
|
it("normalizes string message_id values from rpc result", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true, message_id: " guid-1 " });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
const result = await sendWithDefaults("chat_id:7", "hello");
|
|
expect(result.messageId).toBe("guid-1");
|
|
});
|
|
|
|
it("does not stop an injected client", async () => {
|
|
requestMock.mockClear().mockResolvedValue({ ok: true });
|
|
stopMock.mockClear().mockResolvedValue(undefined);
|
|
|
|
await sendWithDefaults("chat_id:123", "hello");
|
|
expect(stopMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|