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.
93 lines
3.5 KiB
TypeScript
93 lines
3.5 KiB
TypeScript
import BunSQLite from "@moduletrace/bun-sqlite";
|
|
import { AppData } from "@/src/data/app-data";
|
|
import { logError } from "@/src/functions/backend/logger";
|
|
import grabConnectedUserIds from "@/src/websocket/(functions)/grab-connected-user-ids";
|
|
import relayChangedBots from "./relay-changed-bots";
|
|
import relayChangedSessions from "./relay-changed-sessions";
|
|
import relayChangedWorkspaces from "./relay-changed-workspaces";
|
|
import relayCreatedBots from "./relay-created-bots";
|
|
import relayCreatedWorkspaces from "./relay-created-workspaces";
|
|
import relayNewBotMessages from "./relay-new-bot-messages";
|
|
import relayNewJobUpdates from "./relay-new-job-updates";
|
|
import type { LiveRelayCursor } from "./types";
|
|
|
|
/**
|
|
* Keeps this process's sockets current with work done anywhere: every
|
|
* `AppData.LiveRelayPollMs` it reads new bot messages, new job steps,
|
|
* new workspaces and changed bots from the database and pushes them to
|
|
* the sockets here of the users they're for. The process that did the work pushes at once
|
|
* (`pushBotNotice`); this catches the rest, e.g. a job run by another
|
|
* server on the same database. Clients ignore what they already have.
|
|
* Runs in every server process, ungated.
|
|
*/
|
|
export default async function liveRelay() {
|
|
/**
|
|
* A failed read starts the relay from the beginning instead of not
|
|
* starting it: clients drop what they already have.
|
|
*/
|
|
const latest = async (table: "bot_messages" | "bots_queue_updates") => {
|
|
try {
|
|
return (
|
|
Number(
|
|
(
|
|
await BunSQLite.sql<{ id: number }>({
|
|
sql: `SELECT MAX(id) AS id FROM ${table}`,
|
|
})
|
|
).payload?.[0]?.id,
|
|
) || 0
|
|
);
|
|
} catch (error) {
|
|
await logError({
|
|
title: `Live relay couldn't read ${table}`,
|
|
error,
|
|
}).catch(() => {});
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
const cursor: LiveRelayCursor = {
|
|
message_id: await latest("bot_messages"),
|
|
update_id: await latest("bots_queue_updates"),
|
|
bots_at: Date.now(),
|
|
sessions_at: Date.now(),
|
|
};
|
|
|
|
let running = false;
|
|
|
|
setInterval(async () => {
|
|
if (running) return;
|
|
|
|
running = true;
|
|
|
|
try {
|
|
const users = grabConnectedUserIds();
|
|
const now = Date.now();
|
|
|
|
cursor.message_id = await relayNewBotMessages({ after_id: cursor.message_id, users });
|
|
cursor.update_id = await relayNewJobUpdates({ after_id: cursor.update_id, users });
|
|
|
|
/**
|
|
* Overlap one interval, so a change written in the same
|
|
* millisecond as the last look isn't missed.
|
|
*/
|
|
const bots_since = cursor.bots_at - AppData.LiveRelayPollMs;
|
|
|
|
await relayCreatedBots({ since: bots_since, users });
|
|
await relayCreatedWorkspaces({ since: bots_since, users });
|
|
await relayChangedBots({ since: bots_since, users });
|
|
await relayChangedWorkspaces({ since: bots_since, users });
|
|
cursor.bots_at = now;
|
|
|
|
await relayChangedSessions({
|
|
since: cursor.sessions_at - AppData.LiveRelayPollMs,
|
|
users,
|
|
});
|
|
cursor.sessions_at = now;
|
|
} catch (error) {
|
|
await logError({ title: `Live relay failed`, error }).catch(() => {});
|
|
} finally {
|
|
running = false;
|
|
}
|
|
}, AppData.LiveRelayPollMs);
|
|
}
|