Let bots set a picture from a file they can see

A coordinator asked to set an uploaded logo as a workspace picture could
not: the picture tools take a media id, and nothing ever gave the bot one.
Attachments reached the model as "[Attached image \"logo.png\"]", every
file tool answers with a [file:ID] ref, and an SVG is deliberately stored
as a document rather than an image.

- copyMediaAsPicture copies an approved image into a new picture row
  (bot-image/workspace-image). The source keeps its row and bytes, so a
  message that attached it still loads and changing a picture can never
  take a file away from a conversation.
- grabPictureSource resolves a picture by [file:ID] (the same scope gate
  every file tool uses) or by the user's own upload; parseFileRef reads
  the [file:12] form.
- The new set_picture tool ships on every run: a bot sets its own face,
  the coordinator can also dress a worker. update_workspace gained
  picture_file_id, and setBotPicture/setProfileImage share the copy and
  the delete.
- removeReplacedPicture deletes a replaced picture only when it really is
  one, never a chat upload.
- uploadAndRecordMedia writes images in their own format: PNG stays PNG
  with its transparency, WebP stays WebP, GIF becomes PNG, everything
  else JPEG, and the row records the real mime type (it used to say jpeg
  for every image).
- Pictures change live: BotUpdatedData carries image_media_id and a new
  server:workspace-updated pushes workspace pictures, relayed between
  processes.
- Attachments are labelled with their media id, and BotFilesNote tells a
  bot that a [file:ID] can become a picture.
- grabRunScopes is now the single source of a run's reachable folders,
  shared by the file tools, imports and the picture tools.
This commit is contained in:
2026-10-23 05:24:55 +01:00
parent d3357cb7df
commit f2802c1bf3
42 changed files with 1391 additions and 113 deletions
@@ -0,0 +1,37 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabStorageScopes, { type StorageScope } from "@/src/utils/storage-access";
type Params = {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
/**
* The user the run is for: the coordinator reaches their workspaces.
*/
user_id: number | string;
workspace_id?: number | string | null;
};
/**
* The directories one run may touch, and the only place the coordinator's
* workspaces are read for it: its own folder first, then every workspace's
* (`grabStorageScopes`).
*
* Every tool that takes files uses this, so a file a bot can read with
* `list_files` is exactly a file it can set as a picture, attach to a task
* or import into.
*/
export default async function grabRunScopes({ bot, user_id, workspace_id }: Params): Promise<StorageScope[]> {
const all_workspace_ids =
bot.kind == "coordinator"
? (await grabUserWorkspaces({ user_id }))
.filter((workspace) => !workspace.archived)
.map((workspace) => Number(workspace.id))
: [];
return grabStorageScopes({
bot_id: bot.id || 0,
bot_kind: bot.kind,
workspace_id,
all_workspace_ids,
});
}
@@ -0,0 +1,155 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import copyMediaAsPicture, { PictureParadigms } from "./copy-media-as-picture";
/**
* A data directory of its own, with one PNG in it, so a copy reads real
* bytes and the source can be checked to survive.
*/
let data_dir = "";
const source_path = "media/1/9.png";
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
beforeAll(() => {
data_dir = mkdtempSync(path.join(tmpdir(), "cobble-picture-"));
process.env.DATA_DIR = data_dir;
mkdirSync(path.dirname(path.join(data_dir, source_path)), { recursive: true });
writeFileSync(path.join(data_dir, source_path), png);
});
afterAll(() => {
delete process.env.DATA_DIR;
rmSync(data_dir, { recursive: true, force: true });
});
const media = (overrides: Partial<BUN_SQLITE_COBBLE_MEDIA> = {}) =>
({
id: 9,
title: "logo.png",
media_type: "image",
mime_type: "image/png",
media_write_path: source_path,
...overrides,
}) as BUN_SQLITE_COBBLE_MEDIA;
type SaveParams = Parameters<NonNullable<Parameters<typeof copyMediaAsPicture>[0]["save"]>>[0];
/**
* A `save` that records what it was given and answers a new row, so the
* copy is checked without touching sharp.
*/
function recorder() {
const calls: SaveParams[] = [];
return {
calls,
save: async (params: SaveParams) => {
calls.push(params);
return { id: 77, ...params } as BUN_SQLITE_COBBLE_MEDIA;
},
};
}
describe("copyMediaAsPicture", () => {
test("names the paradigm the picture is saved under", () => {
expect(PictureParadigms.bot).toBe("bot-image");
expect(PictureParadigms.workspace).toBe("workspace-image");
});
test("saves a workspace picture under its own paradigm and answers the new id", async () => {
const saved = recorder();
const id = await copyMediaAsPicture({
media: media(),
user_id: 3,
target: "workspace",
title: "Summit Lending picture",
save: saved.save,
});
expect(id).toBe(77);
expect(saved.calls[0]?.paradigms).toEqual(["workspace-image"]);
expect(saved.calls[0]?.title).toBe("Summit Lending picture");
expect(saved.calls[0]?.user_id).toBe(3);
});
test("takes the image's own format, so a PNG is copied as a PNG", async () => {
const saved = recorder();
await copyMediaAsPicture({ media: media(), user_id: 3, target: "bot", save: saved.save });
expect(saved.calls[0]?.ext).toBe("png");
});
test("copies the source's bytes without taking them away", async () => {
const saved = recorder();
await copyMediaAsPicture({ media: media(), user_id: 3, target: "workspace", save: saved.save });
expect(Buffer.from(saved.calls[0]!.data).equals(png)).toBe(true);
expect(await Bun.file(path.join(data_dir, source_path)).exists()).toBe(true);
});
test("refuses anything that isn't an image, and says how to get one", async () => {
const saved = recorder();
await expect(
copyMediaAsPicture({
media: media({ media_type: "document", mime_type: "image/svg+xml" }),
user_id: 3,
target: "workspace",
save: saved.save,
}),
).rejects.toThrow(/turned into a PNG first/);
expect(saved.calls).toHaveLength(0);
});
test("refuses a file over the picture size limit", async () => {
const saved = recorder();
const big_path = "media/1/big.png";
mkdirSync(path.dirname(path.join(data_dir, big_path)), { recursive: true });
writeFileSync(path.join(data_dir, big_path), Buffer.alloc(AppData.ProfileImageMaxBytes + 1));
await expect(
copyMediaAsPicture({
media: media({ media_write_path: big_path }),
user_id: 3,
target: "workspace",
save: saved.save,
}),
).rejects.toThrow(/too big/);
expect(saved.calls).toHaveLength(0);
});
test("refuses a row whose file is gone", async () => {
const saved = recorder();
await expect(
copyMediaAsPicture({
media: media({ media_write_path: "media/1/missing.png" }),
user_id: 3,
target: "workspace",
save: saved.save,
}),
).rejects.toThrow(/missing on the server/);
});
test("refuses a row that is gone", async () => {
const saved = recorder();
await expect(
copyMediaAsPicture({ media: media({ id: 0 }), user_id: 3, target: "bot", save: saved.save }),
).rejects.toThrow(/gone/);
});
});
@@ -0,0 +1,135 @@
import path from "node:path";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { extensionOf } from "@/src/functions/backend/db/bot-files/media-type-for-file";
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
import type { MediaParadigm } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabDirNames from "@/src/utils/grab-dir-names";
export type PictureTarget = "bot" | "workspace";
/**
* The paradigm a bot's and a workspace's picture is saved under: what
* `set-profile-image.ts` and `update-workspace.ts` both read and delete by,
* so a picture is never mistaken for a chat upload or a bot file.
*/
export const PictureParadigms: Record<PictureTarget, MediaParadigm> = {
bot: "bot-image",
workspace: "workspace-image",
};
type SaveParams = {
data: Buffer;
ext: string;
paradigms: MediaParadigm[];
title: string;
user_id: number;
};
type Params = {
/**
* The image to copy: a `media` row the caller already checked the user
* reaches (`grabPictureSource`).
*/
media: BUN_SQLITE_COBBLE_MEDIA;
user_id: number | string;
target: PictureTarget;
/**
* What the new row is called, e.g. "Summit Lending picture".
*/
title?: string | null;
/**
* Writes the copy and answers its new row. Swapped in tests, so the
* copy is checked without sharp.
*/
save?: (params: SaveParams) => Promise<BUN_SQLITE_COBBLE_MEDIA | null>;
};
/**
* Copies an image the user can see into a new picture row: a bot's or a
* workspace's picture is always its own `media` row, in its own paradigm.
*
* A bot is given the files it works with (`[file:ID]`) and the uploads the
* user sent it, never a raw media id, so a picture is copied from what it
* already reaches. The source keeps its row and its bytes: the message
* that attached it still loads, and changing the picture later can never
* take a file away from a conversation.
*/
export default async function copyMediaAsPicture({
media,
user_id,
target,
title,
save = savePictureMedia,
}: Params): Promise<number> {
const id = Number(media.id);
if (!id) {
throw new AppError(`That picture is gone. Pick another one.`);
}
if (media.media_type != "image") {
throw new AppError(
`A picture has to be an image. This one is a ${media.media_type || "file"}: an SVG or a document is turned into a PNG first, then that PNG is set.`,
);
}
const bytes = await readMediaBytes({ media });
if (bytes.length > AppData.ProfileImageMaxBytes) {
throw new AppError(
`That picture is too big. Pictures can be up to ${Math.round(AppData.ProfileImageMaxBytes / 1024 / 1024)} MB.`,
);
}
const saved = await save({
data: bytes,
ext: extensionOf({ name: media.media_write_path, mime_type: media.mime_type }) || "png",
paradigms: [PictureParadigms[target]],
title: title?.trim() || `${target} picture`,
user_id: Number(user_id),
});
if (!saved?.id) {
throw new AppError(`Couldn't save the picture. Try another image.`);
}
return Number(saved.id);
}
/**
* The image's bytes, read from where `media` keeps them.
*/
async function readMediaBytes({ media }: { media: BUN_SQLITE_COBBLE_MEDIA }) {
const { DATA_DIR } = grabDirNames();
if (!media.media_write_path) {
throw new AppError(`Its file is missing on the server. Save it again.`);
}
const file = Bun.file(path.join(DATA_DIR, media.media_write_path));
if (!(await file.exists())) {
throw new AppError(`Its file is missing on the server. Save it again.`);
}
return Buffer.from(await file.arrayBuffer());
}
/**
* Writes the copy as a picture: a private image, owned by the user whose
* crew it is, under its own paradigm.
*/
async function savePictureMedia({ data, ext, paradigms, title, user_id }: SaveParams) {
const res = await uploadAndRecordMedia({
data,
media_type: "image",
media_mime_type: ext,
media_paradigms: paradigms,
media: { title },
user: { id: user_id },
});
return res.singleRes || null;
}
@@ -0,0 +1,64 @@
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
import type { StorageScope } from "@/src/utils/storage-access";
import AppError from "@/src/utils/app-error";
import grabOwnPictureMedia from "./grab-own-picture-media";
type Params = {
user_id: number | string;
/**
* The directories the run may read (`grabStorageScopes`), so a file id
* outside them is refused here rather than by the caller.
*/
scopes?: StorageScope[];
/**
* A file the run can see: its `[file:ID]` ref, which is what every file
* tool answers with.
*/
file_id?: number | null;
/**
* An upload of the user's own, by media id.
*/
media_id?: number | null;
};
/**
* The image a picture is copied from, checked to be one the user may use:
* a file in one of the run's directories (`grabBotFile` decides that, the
* same gate every other file tool uses) or their own upload
* (`grabOwnPictureMedia`).
*
* Exactly one of `file_id` and `media_id` is needed. Null when neither is
* given; an id that isn't reachable throws, so a tool never quietly sets
* someone else's file as a picture.
*/
export default async function grabPictureSource({
user_id,
scopes = [],
file_id,
media_id,
}: Params): Promise<BUN_SQLITE_COBBLE_MEDIA | null> {
if (file_id) {
const file = await grabBotFile({ scopes, file_id });
if (!file?.media?.id) {
throw new AppError(
`No file with that id is in your files. Use list_files to see what you have.`,
);
}
return file.media;
}
if (media_id) {
const media = await grabOwnPictureMedia({ media_id, user_id });
if (!media?.id) {
throw new AppError(`That picture isn't one of yours. Upload it again.`);
}
return media;
}
return null;
}
@@ -0,0 +1,26 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_MEDIA_PARADIGMS } from "@/db/types/db";
import { PictureParadigms, type PictureTarget } from "./copy-media-as-picture";
type Params = {
media_id: number | string;
/**
* Which picture it has to be: a bot's or a workspace's.
*/
target: PictureTarget;
};
/**
* Whether a media row is a picture of that kind. Only one is deleted when
* a picture is replaced: a row a chat message still attaches is left where
* it is, since removing it would break a message the user can still scroll
* to.
*/
export default async function isPictureMedia({ media_id, target }: Params) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA_PARADIGMS>({
sql: `SELECT media_paradigm FROM media_paradigms WHERE media_id = ?`,
values: [Number(media_id)],
});
return (res.payload || []).some((row) => row.media_paradigm == PictureParadigms[target]);
}
@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import removeReplacedPicture from "./remove-replaced-picture";
function spy({ is_picture = true }: { is_picture?: boolean } = {}) {
const asked: unknown[] = [];
const removed: unknown[] = [];
return {
asked,
removed,
isPicture: async (params: { media_id: number | string; target: "bot" | "workspace" }) => {
asked.push(params);
return is_picture;
},
remove: async (params: unknown) => {
removed.push(params);
return { success: true };
},
};
}
describe("removeReplacedPicture", () => {
test("deletes the picture a bot no longer shows", async () => {
const deps = spy();
await removeReplacedPicture({
previous: 4,
next: 9,
user_id: 3,
isPicture: deps.isPicture,
remove: deps.remove,
});
expect(deps.asked).toEqual([{ media_id: 4, target: "bot" }]);
expect(deps.removed).toEqual([{ id: 4, user_id: 3 }]);
});
test("never deletes a row that isn't a picture, such as a chat upload", async () => {
const deps = spy({ is_picture: false });
await removeReplacedPicture({
previous: 4,
next: 9,
user_id: 3,
isPicture: deps.isPicture,
remove: deps.remove,
});
expect(deps.removed).toHaveLength(0);
});
test("leaves a bot that had no picture alone", async () => {
const deps = spy();
await removeReplacedPicture({ previous: null, next: 9, user_id: 3, isPicture: deps.isPicture, remove: deps.remove });
expect(deps.asked).toHaveLength(0);
expect(deps.removed).toHaveLength(0);
});
test("keeps the picture that is still in use, e.g. naming it twice", async () => {
const deps = spy();
await removeReplacedPicture({ previous: 4, next: 4, user_id: 3, isPicture: deps.isPicture, remove: deps.remove });
await removeReplacedPicture({ previous: "4", next: 4, user_id: 3, isPicture: deps.isPicture, remove: deps.remove });
expect(deps.removed).toHaveLength(0);
});
test("asks for the right kind of picture, a workspace's or a bot's", async () => {
const deps = spy();
await removeReplacedPicture({
previous: 4,
next: null,
user_id: 3,
target: "workspace",
isPicture: deps.isPicture,
remove: deps.remove,
});
expect(deps.asked).toEqual([{ media_id: 4, target: "workspace" }]);
});
});
@@ -0,0 +1,48 @@
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import isPictureMedia from "./is-picture-media";
type Params = {
/**
* The picture that was shown before (`bots.image_media_id` or
* `workspaces.image_media_id`).
*/
previous?: number | string | null;
/**
* The one written now; the same id means nothing changed.
*/
next?: number | string | null;
/**
* Whose row it is: a picture is the user's, so deleting it is theirs.
*/
user_id: number | string;
target?: "bot" | "workspace";
/**
* Swapped in tests, so the decision is checked without a database.
*/
isPicture?: typeof isPictureMedia;
remove?: typeof deleteMedia;
};
/**
* Deletes the picture a bot or a workspace no longer shows, so its bytes
* don't stay on disk for nothing.
*
* Only a picture is deleted (`isPictureMedia`): a row a chat message still
* attaches is left where it is, because removing it would break a message
* the user can still scroll to.
*/
export default async function removeReplacedPicture({
previous,
next,
user_id,
target = "bot",
isPicture = isPictureMedia,
remove = deleteMedia,
}: Params) {
if (!previous) return;
if (next != null && Number(previous) == Number(next)) return;
if (!(await isPicture({ media_id: previous, target }))) return;
await remove({ id: previous, user_id: Number(user_id) }).catch(() => {});
}
@@ -0,0 +1,61 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
import AppError from "@/src/utils/app-error";
import copyMediaAsPicture from "./copy-media-as-picture";
import removeReplacedPicture from "./remove-replaced-picture";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The image to copy for it, or null to remove the picture it has.
*/
media?: BUN_SQLITE_COBBLE_MEDIA | null;
};
/**
* Sets (or clears) a bot's picture from an image the caller already checked
* it reaches (`grabPictureSource` hands `copyMediaAsPicture` the row).
*
* Only a super admin or an admin does it, the same rule as the rest of the
* crew: a bot can dress itself, but only for a user who manages the crew.
* The coordinator is allowed a picture, and keeps its name and hue-less
* stone.
*
* Returns the new `image_media_id` (null once removed). `updated_at` moves
* with it, so the live relay pushes the new picture to open tabs.
*/
export default async function setBotPicture({ user_id, bot_id, media }: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
const image_media_id = media?.id
? await copyMediaAsPicture({
media,
user_id,
target: "bot",
title: `${bot.name || "Bot"} picture`,
})
: null;
await BunSQLite.sql({
sql: `UPDATE bots SET image_media_id = ?, updated_at = ? WHERE id = ?`,
values: [image_media_id, Date.now(), bot.id],
});
await removeReplacedPicture({
previous: bot.image_media_id,
next: image_media_id,
user_id,
target: "bot",
});
return { image_media_id, bot: bot as BUN_SQLITE_COBBLE_BOTS };
}
@@ -1,13 +1,14 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
import type { ProfileImageTarget } from "@/src/schemas/profile-image-schema";
import AppError from "@/src/utils/app-error";
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
import { PictureParadigms } from "./copy-media-as-picture";
import removeReplacedPicture from "./remove-replaced-picture";
type Params = {
user_id: number | string;
@@ -19,14 +20,19 @@ type Params = {
data_url: string | null;
};
const Targets = {
bot: { table: "bots", paradigm: "bot-image" },
workspace: { table: "workspaces", paradigm: "workspace-image" },
} as const;
const Tables: Record<ProfileImageTarget, string> = {
bot: "bots",
workspace: "workspaces",
};
/**
* Sets (or removes) the picture of one of the user's bots or workspaces:
* a private image, the old one deleted. Returns the new `image_media_id`.
* a private image, the old picture deleted. Returns the new
* `image_media_id`.
*
* The picture picked here is copied into its own row
* (`PictureParadigms`), and only a picture is deleted when it is replaced
* (`removeReplacedPicture`), never a row a chat message still attaches.
*/
export default async function setProfileImage({ user_id, target, id, data_url }: Params) {
await assertCrewManager({ user_id });
@@ -40,7 +46,7 @@ export default async function setProfileImage({ user_id, target, id, data_url }:
throw new AppError(target == "bot" ? `Bot not found.` : `Workspace not found.`);
}
const { table, paradigm } = Targets[target];
const table = Tables[target];
let image_media_id: number | null = null;
if (data_url) {
@@ -59,8 +65,12 @@ export default async function setProfileImage({ user_id, target, id, data_url }:
const res = await uploadAndRecordMedia({
data: buffer,
media_type: "image",
/**
* The image's own format: a transparent PNG stays a PNG
* (`imageOutputFormat`).
*/
media_mime_type: ext,
media_paradigms: [paradigm],
media_paradigms: [PictureParadigms[target]],
media: { title: `${record.name || target} picture` },
user: { id: Number(user_id) },
});
@@ -77,9 +87,12 @@ export default async function setProfileImage({ user_id, target, id, data_url }:
values: [image_media_id, Date.now(), record.id],
});
if (record.image_media_id) {
await deleteMedia({ id: record.image_media_id, user_id: Number(user_id) }).catch(() => {});
}
await removeReplacedPicture({
previous: record.image_media_id,
next: image_media_id,
user_id,
target,
});
return { image_media_id };
}
@@ -16,15 +16,35 @@ import grabDirNames from "@/src/utils/grab-dir-names";
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
import { existsSync, mkdirSync } from "fs";
import path from "path";
import sharp from "sharp";
import sharp, { type Sharp } from "sharp";
import canExtractText from "@/src/utils/can-extract-text";
import capTextContent from "@/src/utils/cap-text-content";
import fileToText from "./file-to-text";
import MediaDataToArrayBuffer from "./media-data-to-array-buffer";
import sniffMediaExtension from "./sniff-media-ext";
import mediaDataToSharpInput from "./meida-data-to-sharp-input";
import imageOutputFormat, { type ImageOutputFormat } from "@/src/utils/image-output-format";
import deleteMedia from "../../media/delete-media";
/**
* Writes a resized image in the format it came in: PNG stays PNG (with its
* transparency), WebP stays WebP, everything else becomes JPEG.
*/
async function encodeImage({
pipeline,
format,
quality,
}: {
pipeline: Sharp;
format: ImageOutputFormat["format"];
quality: number;
}) {
if (format == "png") return pipeline.png().toBuffer();
if (format == "webp") return pipeline.webp({ quality }).toBuffer();
return pipeline.jpeg({ quality }).toBuffer();
}
type Params = {
media_paradigms?: MediaParadigm[];
media_type?: MediaType;
@@ -102,13 +122,21 @@ export default async function uploadAndRecordMedia({
}
}
/**
* What an image is actually written as: a picture keeps its format, so
* a transparent PNG stays a PNG (`imageOutputFormat`). Every other file
* keeps its own extension.
*/
const image_output = media_type == "image" ? imageOutputFormat({ ext: media_ext }) : null;
const stored_ext = image_output ? image_output.ext : media_ext;
const media_write_path = path.join(
MEDIA_RELATIVE_DIR,
`${target_file_name}.${media_ext}`,
`${target_file_name}.${stored_ext}`,
);
const media_thumbnail_write_path = path.join(
MEDIA_RELATIVE_DIR,
`${target_file_thumbnail_name}.${media_ext}`,
`${target_file_thumbnail_name}.${stored_ext}`,
);
const media_write_path_full = path.join(DATA_DIR, media_write_path);
@@ -157,21 +185,25 @@ export default async function uploadAndRecordMedia({
full_options = { fit: "inside", withoutEnlargement: true };
}
const output = image_output || imageOutputFormat({ ext: media_ext });
const [full, thumb] = await Promise.all([
sharp(buffer)
.resize(full_width, full_height, full_options)
.jpeg({ quality: AppData["ImageQuality"] })
.toBuffer(),
sharp(buffer)
.resize(
encodeImage({
pipeline: sharp(buffer).resize(full_width, full_height, full_options),
format: output.format,
quality: AppData["ImageQuality"],
}),
encodeImage({
pipeline: sharp(buffer).resize(
thumb_width,
is_profile_image ? thumb_width : undefined,
is_profile_image
? { fit: "cover" }
: { fit: "inside", withoutEnlargement: true },
)
.jpeg({ quality: AppData["ImageThumbnailQuality"] })
.toBuffer(),
),
format: output.format,
quality: AppData["ImageThumbnailQuality"],
}),
]);
await Bun.write(media_write_path_full, full);
@@ -241,7 +273,11 @@ export default async function uploadAndRecordMedia({
media_thumbnail_write_path:
media_type === "image" ? media_thumbnail_write_path : undefined,
media_type: media_type as any,
mime_type: media_type == "image" ? "jpeg" : media_ext,
/**
* The image's real type, not always `jpeg`: a stored PNG that the
* row called a JPEG was served and read as one.
*/
mime_type: image_output ? image_output.mime_type : media_ext,
text_content: media_text_content,
};
@@ -1,5 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_WORKSPACES,
BunSQLiteTables,
} from "@/db/types/db";
@@ -8,8 +9,9 @@ import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserWorkspace from "./grab-user-workspace";
import _ from "lodash";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import copyMediaAsPicture from "@/src/functions/backend/db/profile-images/copy-media-as-picture";
import grabOwnPictureMedia from "@/src/functions/backend/db/profile-images/grab-own-picture-media";
import removeReplacedPicture from "@/src/functions/backend/db/profile-images/remove-replaced-picture";
import resolveWorkspacePicture from "@/src/utils/resolve-workspace-picture";
type Params = {
@@ -27,13 +29,20 @@ type Params = {
* it, or undefined to leave it alone.
*/
image_media_id?: number | null;
/**
* The image to copy for the picture, for a caller that already
* resolved one (a bot naming one of its files). Copied, so the
* source's row and bytes stay where they are.
*/
picture_source?: BUN_SQLITE_COBBLE_MEDIA | null;
};
};
/**
* Updates one of the user's workspaces. Its `system_prompt` is shared
* context for every bot in it, and `image_media_id` its picture: a media
* id replaces it (the old picture is deleted), null removes it.
* id replaces it (the old picture is deleted), null removes it, and
* `picture_source` copies an image into a new picture row first.
*/
export default async function updateWorkspace({
user_id,
@@ -50,13 +59,33 @@ export default async function updateWorkspace({
throw new AppError(`Workspace not found.`);
}
const source = data.picture_source;
/**
* A source that is already the picture changes nothing: copying it
* again would only churn a row and delete the old one for no reason.
*/
const requested =
source?.id && Number(source.id) == Number(workspace.image_media_id)
? undefined
: source
? await copyMediaAsPicture({
media: source,
user_id,
target: "workspace",
title: `${workspace.name || "Workspace"} picture`,
})
: data.image_media_id;
const image_media_id = resolveWorkspacePicture({
requested: data.image_media_id,
requested,
current: workspace.image_media_id,
media:
data.image_media_id == null
requested == null
? null
: await grabOwnPictureMedia({ media_id: data.image_media_id, user_id }),
: source
? { id: requested, media_type: "image" }
: await grabOwnPictureMedia({ media_id: requested, user_id }),
});
const res = await BunSQLite.update<
@@ -87,16 +116,15 @@ export default async function updateWorkspace({
/**
* The picture is written, so the one it replaced isn't shown anywhere
* any more.
* any more. Only a picture is deleted (`removeReplacedPicture`): a row
* a chat message still attaches stays, so the message keeps loading.
*/
if (
image_media_id !== undefined &&
workspace.image_media_id &&
Number(workspace.image_media_id) != Number(image_media_id)
) {
await deleteMedia({
id: workspace.image_media_id,
user_id: Number(user_id),
}).catch(() => {});
if (image_media_id !== undefined) {
await removeReplacedPicture({
previous: workspace.image_media_id,
next: image_media_id,
user_id,
target: "workspace",
});
}
}