mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 03:21:17 +00:00
fix(imessage): send group media via attachment command
* fix(imessage): send chat media via attachment command * fix(imessage): satisfy bundled lint --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
This commit is contained in:
@@ -60,34 +60,33 @@ describe("sendMessageIMessage receipts", () => {
|
||||
expect(result.receipt.sentAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("attaches a media receipt after attachment resolution", async () => {
|
||||
it("sends explicit chat media-only payloads through send-attachment auto transport", async () => {
|
||||
const client = createClient({ message_id: 12345 });
|
||||
const runCliJson = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "p:0/media-guid", transferGuid: "transfer-1" });
|
||||
|
||||
const result = await sendMessageIMessage("chat_guid:chat-1", "", {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/image.png",
|
||||
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
|
||||
runCliJson,
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("12345");
|
||||
expect(result.messageId).toBe("p:0/media-guid");
|
||||
expect(result.sentText).toBe("");
|
||||
expect(result.echoText).toBe("<media:image>");
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("12345");
|
||||
expect(result.receipt.platformMessageIds).toEqual(["12345"]);
|
||||
expect(client.request).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({
|
||||
chat_guid: "chat-1",
|
||||
file: "/tmp/image.png",
|
||||
text: "",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("p:0/media-guid");
|
||||
expect(result.receipt.platformMessageIds).toEqual(["p:0/media-guid"]);
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
expect(runCliJson.mock.calls).toEqual([
|
||||
[["send-attachment", "--chat", "chat-1", "--file", "/tmp/image.png", "--transport", "auto"]],
|
||||
]);
|
||||
expect(result.receipt.raw).toEqual([
|
||||
{
|
||||
channel: "imessage",
|
||||
messageId: "12345",
|
||||
messageId: "p:0/media-guid",
|
||||
conversationId: "chat-1",
|
||||
meta: { targetKind: "chat_guid" },
|
||||
},
|
||||
@@ -95,11 +94,11 @@ describe("sendMessageIMessage receipts", () => {
|
||||
expect(result.receipt.parts).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
platformMessageId: "12345",
|
||||
platformMessageId: "p:0/media-guid",
|
||||
kind: "media",
|
||||
raw: {
|
||||
channel: "imessage",
|
||||
messageId: "12345",
|
||||
messageId: "p:0/media-guid",
|
||||
conversationId: "chat-1",
|
||||
meta: { targetKind: "chat_guid" },
|
||||
},
|
||||
@@ -108,6 +107,63 @@ describe("sendMessageIMessage receipts", () => {
|
||||
expect(result.receipt.sentAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolves chat_id media-only payloads before using send-attachment", async () => {
|
||||
const client = createClient({ message_id: 12345 });
|
||||
const runCliJson = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ guid: "any;+;group-guid" })
|
||||
.mockResolvedValueOnce({ messageId: "p:0/media-guid" });
|
||||
|
||||
const result = await sendMessageIMessage("chat_id:42", "", {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/image.png",
|
||||
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
|
||||
runCliJson,
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("p:0/media-guid");
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
expect(runCliJson.mock.calls).toEqual([
|
||||
[["group", "--chat-id", "42"]],
|
||||
[
|
||||
[
|
||||
"send-attachment",
|
||||
"--chat",
|
||||
"any;+;group-guid",
|
||||
"--file",
|
||||
"/tmp/image.png",
|
||||
"--transport",
|
||||
"auto",
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps DM handle media sends on the existing rpc send path", async () => {
|
||||
const client = createClient({ message_id: 12345 });
|
||||
const runCliJson = vi.fn();
|
||||
|
||||
await sendMessageIMessage("+15551234567", "", {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/image.png",
|
||||
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
|
||||
runCliJson,
|
||||
});
|
||||
|
||||
expect(runCliJson).not.toHaveBeenCalled();
|
||||
expect(client.request).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({
|
||||
to: "+15551234567",
|
||||
file: "/tmp/image.png",
|
||||
text: "",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves literal media placeholder text when no attachment is sent", async () => {
|
||||
const client = createClient({ guid: "p:0/imsg-text" });
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
@@ -52,6 +53,7 @@ type IMessageSendOpts = {
|
||||
},
|
||||
) => Promise<{ path: string; contentType?: string }>;
|
||||
createClient?: (params: { cliPath: string; dbPath?: string }) => Promise<IMessageRpcClient>;
|
||||
runCliJson?: (args: readonly string[]) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type IMessageSendResult = {
|
||||
@@ -210,6 +212,110 @@ function resolveOutboundEchoScope(params: {
|
||||
return `${params.accountId}:imessage:${params.target.to}`;
|
||||
}
|
||||
|
||||
function buildIMessageCliJsonArgs(args: readonly string[], dbPath?: string): string[] {
|
||||
const trimmedDbPath = dbPath?.trim();
|
||||
return [...args, ...(trimmedDbPath ? ["--db", trimmedDbPath] : []), "--json"];
|
||||
}
|
||||
|
||||
async function runIMessageCliJson(
|
||||
cliPath: string,
|
||||
dbPath: string | undefined,
|
||||
args: readonly string[],
|
||||
timeoutMs?: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(cliPath, buildIMessageCliJsonArgs(args, dbPath), {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let killEscalation: ReturnType<typeof setTimeout> | null = null;
|
||||
const timer =
|
||||
timeoutMs && timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
killEscalation = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, 2000);
|
||||
reject(new Error(`iMessage action timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (killEscalation) {
|
||||
clearTimeout(killEscalation);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (killEscalation) {
|
||||
clearTimeout(killEscalation);
|
||||
}
|
||||
const lines = stdout
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const last = lines.at(-1);
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
if (last) {
|
||||
try {
|
||||
const json = JSON.parse(last) as unknown;
|
||||
if (json && typeof json === "object" && !Array.isArray(json)) {
|
||||
parsed = json as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// handled below
|
||||
}
|
||||
}
|
||||
if (code === 0 && parsed) {
|
||||
resolve(parsed);
|
||||
return;
|
||||
}
|
||||
if (parsed && typeof parsed.error === "string" && parsed.error.trim()) {
|
||||
reject(new Error(parsed.error.trim()));
|
||||
return;
|
||||
}
|
||||
const detail = stderr.trim() || stdout.trim() || `imsg exited with code ${code}`;
|
||||
reject(new Error(detail));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
async function resolveAttachmentChatGuid(params: {
|
||||
target: ReturnType<typeof parseIMessageTarget>;
|
||||
runCliJson: (args: readonly string[]) => Promise<Record<string, unknown>>;
|
||||
}): Promise<string | null> {
|
||||
if (params.target.kind === "chat_guid") {
|
||||
return params.target.chatGuid;
|
||||
}
|
||||
if (params.target.kind !== "chat_id") {
|
||||
return null;
|
||||
}
|
||||
const result = await params.runCliJson(["group", "--chat-id", String(params.target.chatId)]);
|
||||
return stringValue(result.guid) ?? stringValue(result.chat_guid) ?? null;
|
||||
}
|
||||
|
||||
export async function sendMessageIMessage(
|
||||
to: string,
|
||||
text: string,
|
||||
@@ -278,6 +384,56 @@ export async function sendMessageIMessage(
|
||||
}
|
||||
const echoText = resolveOutboundEchoText(message, filePath ? mediaContentType : undefined);
|
||||
const resolvedReplyToId = sanitizeReplyToId(opts.replyToId);
|
||||
const runCliJson =
|
||||
opts.runCliJson ??
|
||||
((args: readonly string[]) => runIMessageCliJson(cliPath, dbPath, args, opts.timeoutMs));
|
||||
|
||||
if (filePath && !message.trim() && !resolvedReplyToId) {
|
||||
const attachmentChatGuid = await resolveAttachmentChatGuid({ target, runCliJson });
|
||||
if (attachmentChatGuid) {
|
||||
const result = await runCliJson([
|
||||
"send-attachment",
|
||||
"--chat",
|
||||
attachmentChatGuid,
|
||||
"--file",
|
||||
filePath,
|
||||
"--transport",
|
||||
"auto",
|
||||
]);
|
||||
const resolvedId = resolveMessageId(result);
|
||||
const approvalBindingMessageId = resolveOutboundMessageGuid(result);
|
||||
const messageId = resolvedId ?? (result?.ok || result?.success ? "ok" : "unknown");
|
||||
const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target });
|
||||
if (echoScope) {
|
||||
rememberPersistedIMessageEcho({
|
||||
scope: echoScope,
|
||||
text: echoText,
|
||||
messageId: resolvedId ?? undefined,
|
||||
});
|
||||
}
|
||||
if (resolvedId) {
|
||||
rememberIMessageReplyCache({
|
||||
accountId: account.accountId,
|
||||
messageId: resolvedId,
|
||||
chatGuid: target.kind === "chat_guid" ? target.chatGuid : attachmentChatGuid,
|
||||
chatId: target.kind === "chat_id" ? target.chatId : undefined,
|
||||
timestamp: Date.now(),
|
||||
isFromMe: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
messageId,
|
||||
...(approvalBindingMessageId ? { guid: approvalBindingMessageId } : {}),
|
||||
sentText: message,
|
||||
...(echoText ? { echoText } : {}),
|
||||
receipt: createIMessageSendReceipt({
|
||||
messageId,
|
||||
target,
|
||||
kind: "media",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
const params: Record<string, unknown> = {
|
||||
text: message,
|
||||
service: service || "auto",
|
||||
|
||||
Reference in New Issue
Block a user