Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2517,6 +2517,7 @@ public struct CronJob: Codable, Sendable {
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: AnyCodable?
public let pacing: [String: AnyCodable]?
public let failurealert: AnyCodable?
public let state: [String: AnyCodable]

Expand All @@ -2535,6 +2536,7 @@ public struct CronJob: Codable, Sendable {
wakemode: AnyCodable,
payload: AnyCodable,
delivery: AnyCodable?,
pacing: [String: AnyCodable]?,
failurealert: AnyCodable?,
state: [String: AnyCodable])
{
Expand All @@ -2552,6 +2554,7 @@ public struct CronJob: Codable, Sendable {
self.wakemode = wakemode
self.payload = payload
self.delivery = delivery
self.pacing = pacing
self.failurealert = failurealert
self.state = state
}
Expand All @@ -2571,6 +2574,7 @@ public struct CronJob: Codable, Sendable {
case wakemode = "wakeMode"
case payload
case delivery
case pacing
case failurealert = "failureAlert"
case state
}
Expand Down Expand Up @@ -2628,6 +2632,7 @@ public struct CronAddParams: Codable, Sendable {
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: AnyCodable?
public let pacing: [String: AnyCodable]?
public let failurealert: AnyCodable?

public init(
Expand All @@ -2642,6 +2647,7 @@ public struct CronAddParams: Codable, Sendable {
wakemode: AnyCodable,
payload: AnyCodable,
delivery: AnyCodable?,
pacing: [String: AnyCodable]?,
failurealert: AnyCodable?)
{
self.name = name
Expand All @@ -2655,6 +2661,7 @@ public struct CronAddParams: Codable, Sendable {
self.wakemode = wakemode
self.payload = payload
self.delivery = delivery
self.pacing = pacing
self.failurealert = failurealert
}

Expand All @@ -2670,6 +2677,7 @@ public struct CronAddParams: Codable, Sendable {
case wakemode = "wakeMode"
case payload
case delivery
case pacing
case failurealert = "failureAlert"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2517,6 +2517,7 @@ public struct CronJob: Codable, Sendable {
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: AnyCodable?
public let pacing: [String: AnyCodable]?
public let failurealert: AnyCodable?
public let state: [String: AnyCodable]

Expand All @@ -2535,6 +2536,7 @@ public struct CronJob: Codable, Sendable {
wakemode: AnyCodable,
payload: AnyCodable,
delivery: AnyCodable?,
pacing: [String: AnyCodable]?,
failurealert: AnyCodable?,
state: [String: AnyCodable])
{
Expand All @@ -2552,6 +2554,7 @@ public struct CronJob: Codable, Sendable {
self.wakemode = wakemode
self.payload = payload
self.delivery = delivery
self.pacing = pacing
self.failurealert = failurealert
self.state = state
}
Expand All @@ -2571,6 +2574,7 @@ public struct CronJob: Codable, Sendable {
case wakemode = "wakeMode"
case payload
case delivery
case pacing
case failurealert = "failureAlert"
case state
}
Expand Down Expand Up @@ -2628,6 +2632,7 @@ public struct CronAddParams: Codable, Sendable {
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: AnyCodable?
public let pacing: [String: AnyCodable]?
public let failurealert: AnyCodable?

public init(
Expand All @@ -2642,6 +2647,7 @@ public struct CronAddParams: Codable, Sendable {
wakemode: AnyCodable,
payload: AnyCodable,
delivery: AnyCodable?,
pacing: [String: AnyCodable]?,
failurealert: AnyCodable?)
{
self.name = name
Expand All @@ -2655,6 +2661,7 @@ public struct CronAddParams: Codable, Sendable {
self.wakemode = wakemode
self.payload = payload
self.delivery = delivery
self.pacing = pacing
self.failurealert = failurealert
}

Expand All @@ -2670,6 +2677,7 @@ public struct CronAddParams: Codable, Sendable {
case wakemode = "wakeMode"
case payload
case delivery
case pacing
case failurealert = "failureAlert"
}
}
Expand Down
99 changes: 99 additions & 0 deletions src/lionroot/routing/food-image-upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { __test__, uploadFoodImageToCommandPost } from "./food-image-upload.js";

afterEach(() => {
vi.restoreAllMocks();
});

describe("uploadFoodImageToCommandPost", () => {
it("uploads image with multipart form data and auth header", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "food-image-upload-"));
const imagePath = path.join(tempDir, "meal.heic");
await fs.writeFile(imagePath, Buffer.from("fake-image-data"));

let capturedBody: FormData | undefined;
const fetchMock = vi.fn().mockImplementation(async (_url, init) => {
capturedBody = init?.body as FormData;
return {
ok: true,
text: async () => "",
};
});
vi.stubGlobal("fetch", fetchMock);

const result = await uploadFoodImageToCommandPost({
imagePath,
mealText: "breakfast with coffee and eggs",
date: "2026-03-14",
endpointUrl: "http://127.0.0.1:3005/api/inbox/intake/food-image",
bearerToken: "secret-token",
});

expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledOnce();
const [, init] = fetchMock.mock.calls[0];
expect(init?.method).toBe("POST");
expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer secret-token");
expect(capturedBody).toBeInstanceOf(FormData);
expect(capturedBody?.get("meal")).toBe("breakfast with coffee and eggs");
expect(capturedBody?.get("date")).toBe("2026-03-14");
expect(capturedBody?.get("image")).toBeInstanceOf(File);
expect((capturedBody?.get("image") as File).name).toBe("meal.heic");
expect((capturedBody?.get("image") as File).type).toBe("image/heic");
});

it("returns an http error response body when upload fails", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "food-image-upload-"));
const imagePath = path.join(tempDir, "meal.jpg");
await fs.writeFile(imagePath, Buffer.from("fake-image-data"));

vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: async () => "unauthorized",
}),
);

const result = await uploadFoodImageToCommandPost({
imagePath,
mealText: "lunch",
endpointUrl: "http://127.0.0.1:3005/api/inbox/intake/food-image",
bearerToken: "bad-token",
});

expect(result.ok).toBe(false);
expect(result.error).toContain("HTTP 401");
expect(result.error).toContain("unauthorized");
});

it("returns an error when the image file cannot be read", async () => {
const result = await uploadFoodImageToCommandPost({
imagePath: "/definitely/missing/meal.jpg",
mealText: "dinner",
endpointUrl: "http://127.0.0.1:3005/api/inbox/intake/food-image",
bearerToken: "secret-token",
});

expect(result.ok).toBe(false);
expect(result.error).toContain("ENOENT");
});

it("uses a local yyyy-mm-dd date when date is omitted", async () => {
const date = __test__.todayDateStr(new Date("2026-03-14T20:15:00-04:00"));
expect(date).toBe("2026-03-14");
});

it("maps common image extensions", () => {
expect(__test__.mimeFromExtension("meal.jpg")).toBe("image/jpeg");
expect(__test__.mimeFromExtension("meal.jpeg")).toBe("image/jpeg");
expect(__test__.mimeFromExtension("meal.png")).toBe("image/png");
expect(__test__.mimeFromExtension("meal.heic")).toBe("image/heic");
expect(__test__.mimeFromExtension("meal.webp")).toBe("image/webp");
expect(__test__.mimeFromExtension("meal.bin")).toBe("application/octet-stream");
});
});
74 changes: 74 additions & 0 deletions src/lionroot/routing/food-image-upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import fs from "node:fs/promises";
import path from "node:path";

const DEFAULT_TIMEOUT_MS = 15_000;

function mimeFromExtension(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const map: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".heic": "image/heic",
".heif": "image/heif",
".webp": "image/webp",
".gif": "image/gif",
};
return map[ext] ?? "application/octet-stream";
}

function todayDateStr(now: Date = new Date()): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}

export async function uploadFoodImageToCommandPost(params: {
imagePath: string;
mealText: string;
date?: string;
capturedAt?: string;
endpointUrl: string;
bearerToken: string;
timeoutMs?: number;
}): Promise<{ ok: boolean; error?: string }> {
try {
const fileBuffer = await fs.readFile(params.imagePath);
const fileName = path.basename(params.imagePath);
const formData = new FormData();
formData.append(
"image",
new Blob([fileBuffer], { type: mimeFromExtension(params.imagePath) }),
fileName,
);
formData.append("meal", params.mealText);
formData.append("date", params.date ?? todayDateStr());
if (params.capturedAt) {
formData.append("capturedAt", params.capturedAt);
}

const response = await fetch(params.endpointUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${params.bearerToken}`,
},
body: formData,
signal: AbortSignal.timeout(params.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});

if (!response.ok) {
const responseText = await response.text().catch(() => "");
return {
ok: false,
error: `HTTP ${response.status}${responseText ? `: ${responseText.slice(0, 200)}` : ""}`,
};
}

return { ok: true };
} catch (error) {
return { ok: false, error: String(error) };
}
}

export const __test__ = {
mimeFromExtension,
todayDateStr,
};
Loading