refactor: rewrite API to Elysia #25

Merged
pinks merged 2 commits from elysia into main 2023-11-20 02:14:15 +00:00
21 changed files with 696 additions and 615 deletions
Showing only changes of commit d2157063e5 - Show all commits

View File

@ -1,129 +1,107 @@
import { Model } from "indexed_kv"; import { Model } from "indexed_kv";
import { info } from "std/log/mod.ts"; import { info } from "std/log/mod.ts";
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; import { Elysia, t } from "elysia";
import { Admin, adminSchema, adminStore } from "../app/adminStore.ts"; import { Admin, adminSchema, adminStore } from "../app/adminStore.ts";
import { getUser, withAdmin, withUser } from "./withUser.ts"; import { getUser, withAdmin, withUser } from "./withUser.ts";
export type AdminData = Admin & { id: string }; export type AdminData = Admin & { id: string };
const adminDataSchema = t.Object({
id: t.String(),
tgUserId: t.Number(),
promotedBy: t.Nullable(t.String()),
});
function getAdminData(adminEntry: Model<Admin>): AdminData { function getAdminData(adminEntry: Model<Admin>): AdminData {
return { id: adminEntry.id, ...adminEntry.value }; return { id: adminEntry.id, ...adminEntry.value };
} }
export const adminsRoute = createPathFilter({ export const adminsRoute = new Elysia()
"": createMethodFilter({ .get(
GET: createEndpoint( "",
{}, async () => {
async () => { const adminEntries = await adminStore.getAll();
const admins = adminEntries.map(getAdminData);
return admins;
},
{
response: t.Array(adminDataSchema),
},
)
.post(
"",
async ({ query, body }) => {
return withAdmin(query, async (user, adminEntry) => {
const newAdminUser = await getUser(body.tgUserId);
const newAdminEntry = await adminStore.create({
tgUserId: body.tgUserId,
promotedBy: adminEntry.id,
});
info(`User ${user.first_name} promoted user ${newAdminUser.first_name} to admin`);
return getAdminData(newAdminEntry);
});
},
{
query: t.Object({ sessionId: t.String() }),
body: t.Object({
tgUserId: t.Number(),
}),
response: adminDataSchema,
},
)
.post(
"/promote_self",
// if there are no admins, allow any user to promote themselves
async ({ query }) => {
return withUser(query, async (user) => {
const adminEntries = await adminStore.getAll(); const adminEntries = await adminStore.getAll();
const admins = adminEntries.map(getAdminData); if (adminEntries.length === 0) {
return {
status: 200,
body: { type: "application/json", data: admins satisfies AdminData[] },
};
},
),
POST: createEndpoint(
{
query: {
sessionId: { type: "string" },
},
body: {
type: "application/json",
schema: {
type: "object",
properties: {
tgUserId: adminSchema.properties.tgUserId,
},
required: ["tgUserId"],
},
},
},
async ({ query, body }) => {
return withAdmin(query, async (user, adminEntry) => {
const newAdminUser = await getUser(body.data.tgUserId);
const newAdminEntry = await adminStore.create({ const newAdminEntry = await adminStore.create({
tgUserId: body.data.tgUserId, tgUserId: user.id,
promotedBy: adminEntry.id, promotedBy: null,
}); });
info(`User ${user.first_name} promoted user ${newAdminUser.first_name} to admin`); info(`User ${user.first_name} promoted themselves to admin`);
return { return getAdminData(newAdminEntry);
status: 200, }
body: { type: "application/json", data: getAdminData(newAdminEntry) }, throw new Error("You are not allowed to promote yourself");
}; });
}); },
}, {
), query: t.Object({ sessionId: t.String() }),
}), response: adminDataSchema,
},
"promote_self": createMethodFilter({ )
POST: createEndpoint( .get(
{ "/:adminId",
query: { async ({ params }) => {
sessionId: { type: "string" }, const adminEntry = await adminStore.getById(params.adminId!);
}, if (!adminEntry) {
}, throw new Error("Admin not found");
// if there are no admins, allow any user to promote themselves }
async ({ query }) => { return getAdminData(adminEntry);
return withUser(query, async (user) => { },
const adminEntries = await adminStore.getAll(); {
if (adminEntries.length === 0) { params: t.Object({ adminId: t.String() }),
const newAdminEntry = await adminStore.create({ response: adminDataSchema,
tgUserId: user.id, },
promotedBy: null, )
}); .delete(
info(`User ${user.first_name} promoted themselves to admin`); "/:adminId",
return { async ({ params, query }) => {
status: 200, return withAdmin(query, async (chat) => {
body: { type: "application/json", data: getAdminData(newAdminEntry) }, const deletedAdminEntry = await adminStore.getById(params.adminId!);
}; if (!deletedAdminEntry) {
} throw new Error("Admin not found");
return { }
status: 403, const deletedAdminUser = await getUser(deletedAdminEntry.value.tgUserId);
body: { type: "text/plain", data: `You are not allowed to promote yourself` }, await deletedAdminEntry.delete();
}; info(`User ${chat.first_name} demoted user ${deletedAdminUser.first_name} from admin`);
}); return null;
}, });
), },
}), {
params: t.Object({ adminId: t.String() }),
"{adminId}": createPathFilter({ query: t.Object({ sessionId: t.String() }),
"": createMethodFilter({ response: t.Null(),
GET: createEndpoint( },
{}, );
async ({ params }) => {
const adminEntry = await adminStore.getById(params.adminId!);
if (!adminEntry) {
return { status: 404, body: { type: "text/plain", data: `Admin not found` } };
}
return {
status: 200,
body: { type: "application/json", data: getAdminData(adminEntry) },
};
},
),
DELETE: createEndpoint(
{
query: {
sessionId: { type: "string" },
},
},
async ({ params, query }) => {
return withAdmin(query, async (chat) => {
const deletedAdminEntry = await adminStore.getById(params.adminId!);
if (!deletedAdminEntry) {
return { status: 404, body: { type: "text/plain", data: `Admin not found` } };
}
const deletedAdminUser = await getUser(deletedAdminEntry.value.tgUserId);
await deletedAdminEntry.delete();
info(`User ${chat.first_name} demoted user ${deletedAdminUser.first_name} from admin`);
return {
status: 200,
body: { type: "application/json", data: null },
};
});
},
),
}),
}),
});

View File

@ -1,9 +1,14 @@
import { createEndpoint, createMethodFilter } from "t_rest/server"; import { Elysia, t } from "elysia";
import { bot } from "../bot/mod.ts"; import { bot } from "../bot/mod.ts";
export const botRoute = createMethodFilter({ export const botRoute = new Elysia()
GET: createEndpoint({ query: null, body: null }, async () => { .get(
const username = bot.botInfo.username; "",
return { status: 200, body: { type: "application/json", data: { username } } }; async () => {
}), const username = bot.botInfo.username;
}); return { username };
},
{
response: t.Object({ username: t.String() }),
},
);

View File

@ -1,32 +1,40 @@
import { createEndpoint, createMethodFilter } from "t_rest/server"; import { Elysia, t } from "elysia";
import { generationQueue } from "../app/generationQueue.ts"; import { generationQueue } from "../app/generationQueue.ts";
export const jobsRoute = createMethodFilter({ export const jobsRoute = new Elysia()
GET: createEndpoint( .get(
{ query: null, body: null }, "",
async () => { async () => {
const allJobs = await generationQueue.getAllJobs(); const allJobs = await generationQueue.getAllJobs();
const filteredJobsData = allJobs.map((job) => ({ return allJobs.map((job) => ({
id: job.id, id: job.id.join(":"),
place: job.place, place: job.place,
state: { state: {
from: { from: {
language_code: job.state.from.language_code, language_code: job.state.from.language_code ?? null,
first_name: job.state.from.first_name, first_name: job.state.from.first_name,
last_name: job.state.from.last_name, last_name: job.state.from.last_name ?? null,
username: job.state.from.username, username: job.state.from.username ?? null,
}, },
progress: job.state.progress, progress: job.state.progress ?? null,
workerInstanceKey: job.state.workerInstanceKey, workerInstanceKey: job.state.workerInstanceKey ?? null,
}, },
})); }));
return {
status: 200,
body: {
type: "application/json",
data: filteredJobsData,
},
};
}, },
), {
}); response: t.Array(t.Object({
id: t.String(),
place: t.Number(),
state: t.Object({
from: t.Object({
language_code: t.Nullable(t.String()),
first_name: t.String(),
last_name: t.Nullable(t.String()),
username: t.Nullable(t.String()),
}),
progress: t.Nullable(t.Number()),
workerInstanceKey: t.Nullable(t.String()),
}),
})),
},
);

View File

@ -1,12 +1,12 @@
import { route } from "reroute"; import { route } from "reroute";
import { serveSpa } from "serve_spa"; import { serveSpa } from "serve_spa";
import { serveApi } from "./serveApi.ts"; import { api } from "./serveApi.ts";
import { fromFileUrl } from "std/path/mod.ts"; import { fromFileUrl } from "std/path/mod.ts";
export async function serveUi() { export async function serveUi() {
const server = Deno.serve({ port: 5999 }, (request) => const server = Deno.serve({ port: 5999 }, (request) =>
route(request, { route(request, {
"/api/*": (request) => serveApi(request), "/api/*": (request) => api.fetch(request),
"/*": (request) => "/*": (request) =>
serveSpa(request, { serveSpa(request, {
fsRoot: fromFileUrl(new URL("../ui/", import.meta.url)), fsRoot: fromFileUrl(new URL("../ui/", import.meta.url)),

View File

@ -1,34 +1,46 @@
import { Elysia, t } from "elysia";
import { deepMerge } from "std/collections/deep_merge.ts"; import { deepMerge } from "std/collections/deep_merge.ts";
import { info } from "std/log/mod.ts"; import { info } from "std/log/mod.ts";
import { createEndpoint, createMethodFilter } from "t_rest/server"; import { getConfig, setConfig } from "../app/config.ts";
import { configSchema, getConfig, setConfig } from "../app/config.ts"; import { omitUndef } from "../utils/omitUndef.ts";
import { withAdmin } from "./withUser.ts"; import { withAdmin } from "./withUser.ts";
export const paramsRoute = createMethodFilter({ const paramsSchema = t.Partial(t.Object({
GET: createEndpoint( batch_size: t.Number(),
{ query: null, body: null }, n_iter: t.Number(),
width: t.Number(),
height: t.Number(),
steps: t.Number(),
cfg_scale: t.Number(),
sampler_name: t.String(),
negative_prompt: t.String(),
}));
export const paramsRoute = new Elysia()
.get(
"",
async () => { async () => {
const config = await getConfig(); const config = await getConfig();
return { status: 200, body: { type: "application/json", data: config.defaultParams } }; return omitUndef(config.defaultParams);
}, },
),
PATCH: createEndpoint(
{ {
query: { sessionId: { type: "string" } }, response: paramsSchema,
body: {
type: "application/json",
schema: configSchema.properties.defaultParams,
},
}, },
)
.patch(
"",
async ({ query, body }) => { async ({ query, body }) => {
return withAdmin(query, async (user) => { return withAdmin(query, async (user) => {
const config = await getConfig(); const config = await getConfig();
info(`User ${user.first_name} updated default params: ${JSON.stringify(body.data)}`); info(`User ${user.first_name} updated default params: ${JSON.stringify(body)}`);
const defaultParams = deepMerge(config.defaultParams ?? {}, body.data); const defaultParams = deepMerge(config.defaultParams ?? {}, body);
await setConfig({ defaultParams }); await setConfig({ defaultParams });
return { status: 200, body: { type: "application/json", data: config.defaultParams } }; return omitUndef(config.defaultParams);
}); });
}, },
), {
}); query: t.Object({ sessionId: t.String() }),
body: paramsSchema,
response: paramsSchema,
},
);

View File

@ -1,4 +1,5 @@
import { createLoggerMiddleware, createPathFilter } from "t_rest/server"; import { Elysia } from "elysia";
import { swagger } from "elysia/swagger";
import { adminsRoute } from "./adminsRoute.ts"; import { adminsRoute } from "./adminsRoute.ts";
import { botRoute } from "./botRoute.ts"; import { botRoute } from "./botRoute.ts";
import { jobsRoute } from "./jobsRoute.ts"; import { jobsRoute } from "./jobsRoute.ts";
@ -8,18 +9,24 @@ import { statsRoute } from "./statsRoute.ts";
import { usersRoute } from "./usersRoute.ts"; import { usersRoute } from "./usersRoute.ts";
import { workersRoute } from "./workersRoute.ts"; import { workersRoute } from "./workersRoute.ts";
export const serveApi = createLoggerMiddleware( export const api = new Elysia()
createPathFilter({ .use(
"admins": adminsRoute, swagger({
"bot": botRoute, path: "/docs",
"jobs": jobsRoute, swaggerOptions: { url: "docs/json" } as never,
"sessions": sessionsRoute, documentation: {
"settings/params": paramsRoute, info: { title: "Eris API", version: "0.1" },
"stats": statsRoute, servers: [{ url: "/api" }],
"users": usersRoute, },
"workers": workersRoute, }),
}), )
{ filterStatus: (status) => status >= 400 }, .group("/admins", (api) => api.use(adminsRoute))
); .group("/bot", (api) => api.use(botRoute))
.group("/jobs", (api) => api.use(jobsRoute))
.group("/sessions", (api) => api.use(sessionsRoute))
.group("/settings/params", (api) => api.use(paramsRoute))
.group("/stats", (api) => api.use(statsRoute))
.group("/users", (api) => api.use(usersRoute))
.group("/workers", (api) => api.use(workersRoute));
export type ApiHandler = typeof serveApi; export type Api = typeof api;

View File

@ -1,4 +1,4 @@
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; import { Elysia, t } from "elysia";
import { ulid } from "ulid"; import { ulid } from "ulid";
export const sessions = new Map<string, Session>(); export const sessions = new Map<string, Session>();
@ -7,30 +7,37 @@ export interface Session {
userId?: number | undefined; userId?: number | undefined;
} }
export const sessionsRoute = createPathFilter({ export const sessionsRoute = new Elysia()
"": createMethodFilter({ .post(
POST: createEndpoint( "",
{ query: null, body: null }, async () => {
async () => { const id = ulid();
const id = ulid(); const session: Session = {};
const session: Session = {}; sessions.set(id, session);
sessions.set(id, session); return { id, userId: session.userId ?? null };
return { status: 200, body: { type: "application/json", data: { id, ...session } } }; },
}, {
), response: t.Object({
}), id: t.String(),
userId: t.Nullable(t.Number()),
"{sessionId}": createMethodFilter({ }),
GET: createEndpoint( },
{ query: null, body: null }, )
async ({ params }) => { .get(
const id = params.sessionId!; "/:sessionId",
const session = sessions.get(id); async ({ params }) => {
if (!session) { const id = params.sessionId!;
return { status: 401, body: { type: "text/plain", data: "Session not found" } }; const session = sessions.get(id);
} if (!session) {
return { status: 200, body: { type: "application/json", data: { id, ...session } } }; throw new Error("Session not found");
}, }
), return { id, userId: session.userId ?? null };
}), },
}); {
params: t.Object({ sessionId: t.String() }),
response: t.Object({
id: t.String(),
userId: t.Nullable(t.Number()),
}),
},
);

View File

@ -1,5 +1,5 @@
import { Elysia, t } from "elysia";
import { subMinutes } from "date-fns"; import { subMinutes } from "date-fns";
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
import { getDailyStats } from "../app/dailyStatsStore.ts"; import { getDailyStats } from "../app/dailyStatsStore.ts";
import { generationStore } from "../app/generationStore.ts"; import { generationStore } from "../app/generationStore.ts";
import { globalStats } from "../app/globalStats.ts"; import { globalStats } from "../app/globalStats.ts";
@ -9,138 +9,149 @@ import { withAdmin } from "./withUser.ts";
const STATS_INTERVAL_MIN = 3; const STATS_INTERVAL_MIN = 3;
export const statsRoute = createPathFilter({ export const statsRoute = new Elysia()
"": createMethodFilter({ .get(
GET: createEndpoint( "",
{ query: null, body: null }, async () => {
async () => { const after = subMinutes(new Date(), STATS_INTERVAL_MIN);
const after = subMinutes(new Date(), STATS_INTERVAL_MIN); const generations = await generationStore.getAll({ after });
const generations = await generationStore.getAll({ after });
const imagesPerMinute = generations.length / STATS_INTERVAL_MIN; const imagesPerMinute = generations.length / STATS_INTERVAL_MIN;
const stepsPerMinute = generations const stepsPerMinute = generations
.map((generation) => generation.value.info?.steps ?? 0) .map((generation) => generation.value.info?.steps ?? 0)
.reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN; .reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN;
const pixelsPerMinute = generations const pixelsPerMinute = generations
.map((generation) => .map((generation) =>
(generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0)
) )
.reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN; .reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN;
const pixelStepsPerMinute = generations const pixelStepsPerMinute = generations
.map((generation) => .map((generation) =>
(generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) * (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) *
(generation.value.info?.steps ?? 0) (generation.value.info?.steps ?? 0)
) )
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN; .reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
return { return {
status: 200, imageCount: globalStats.imageCount,
body: { stepCount: globalStats.stepCount,
type: "application/json", pixelCount: globalStats.pixelCount,
data: { pixelStepCount: globalStats.pixelStepCount,
imageCount: globalStats.imageCount, userCount: globalStats.userIds.length,
stepCount: globalStats.stepCount, imagesPerMinute,
pixelCount: globalStats.pixelCount, stepsPerMinute,
pixelStepCount: globalStats.pixelStepCount, pixelsPerMinute,
userCount: globalStats.userIds.length, pixelStepsPerMinute,
imagesPerMinute, };
stepsPerMinute, },
pixelsPerMinute, {
pixelStepsPerMinute, response: t.Object({
}, imageCount: t.Number(),
}, stepCount: t.Number(),
}; pixelCount: t.Number(),
}, pixelStepCount: t.Number(),
), userCount: t.Number(),
}), imagesPerMinute: t.Number(),
"daily/{year}/{month}/{day}": createMethodFilter({ stepsPerMinute: t.Number(),
GET: createEndpoint( pixelsPerMinute: t.Number(),
{ query: null, body: null }, pixelStepsPerMinute: t.Number(),
async ({ params }) => { }),
const year = Number(params.year); },
const month = Number(params.month); )
const day = Number(params.day); .get(
const stats = await getDailyStats(year, month, day); "/daily/:year/:month/:day",
return { async ({ params }) => {
status: 200, const year = Number(params.year);
body: { const month = Number(params.month);
type: "application/json", const day = Number(params.day);
data: { const stats = await getDailyStats(year, month, day);
imageCount: stats.imageCount, return {
pixelCount: stats.pixelCount, imageCount: stats.imageCount,
userCount: stats.userIds.length, pixelCount: stats.pixelCount,
timestamp: stats.timestamp, userCount: stats.userIds.length,
}, timestamp: stats.timestamp,
}, };
}; },
}, {
), params: t.Object({
}), year: t.Number(),
"users/{userId}": createMethodFilter({ month: t.Number(),
GET: createEndpoint( day: t.Number(),
{ query: null, body: null }, }),
async ({ params }) => { response: t.Object({
imageCount: t.Number(),
pixelCount: t.Number(),
userCount: t.Number(),
timestamp: t.Number(),
}),
},
)
.get(
"/users/:userId",
async ({ params }) => {
const userId = params.userId;
const stats = await getUserStats(userId);
return {
imageCount: stats.imageCount,
pixelCount: stats.pixelCount,
timestamp: stats.timestamp,
};
},
{
params: t.Object({ userId: t.Number() }),
response: t.Object({
imageCount: t.Number(),
pixelCount: t.Number(),
timestamp: t.Number(),
}),
},
)
.get(
"/users/:userId/tagcount",
async ({ params, query }) => {
return withAdmin(query, async () => {
const userId = Number(params.userId); const userId = Number(params.userId);
const stats = await getUserStats(userId); const stats = await getUserStats(userId);
return { return {
status: 200, tagCountMap: stats.tagCountMap,
body: { timestamp: stats.timestamp,
type: "application/json",
data: {
imageCount: stats.imageCount,
pixelCount: stats.pixelCount,
timestamp: stats.timestamp,
},
},
}; };
}, });
), },
}), {
"users/{userId}/tagcount": createMethodFilter({ params: t.Object({ userId: t.Number() }),
GET: createEndpoint( query: t.Object({ sessionId: t.String() }),
{ query: { sessionId: { type: "string" } }, body: null }, response: t.Object({
async ({ params, query }) => { tagCountMap: t.Record(t.String(), t.Number()),
return withAdmin(query, async () => { timestamp: t.Number(),
const userId = Number(params.userId); }),
const stats = await getUserStats(userId); },
return { )
status: 200, .get(
body: { "/users/:userId/daily/:year/:month/:day",
type: "application/json", async ({ params }) => {
data: { const { userId, year, month, day } = params;
tagCountMap: stats.tagCountMap, const stats = await getUserDailyStats(userId, year, month, day);
timestamp: stats.timestamp, return {
}, imageCount: stats.imageCount,
}, pixelCount: stats.pixelCount,
}; timestamp: stats.timestamp,
}); };
}, },
), {
}), params: t.Object({
"users/{userId}/daily/{year}/{month}/{day}": createMethodFilter({ userId: t.Number(),
GET: createEndpoint( year: t.Number(),
{ query: null, body: null }, month: t.Number(),
async ({ params }) => { day: t.Number(),
const userId = Number(params.userId); }),
const year = Number(params.year); response: t.Object({
const month = Number(params.month); imageCount: t.Number(),
const day = Number(params.day); pixelCount: t.Number(),
const stats = await getUserDailyStats(userId, year, month, day); timestamp: t.Number(),
return { }),
status: 200, },
body: { );
type: "application/json",
data: {
imageCount: stats.imageCount,
pixelCount: stats.pixelCount,
timestamp: stats.timestamp,
},
},
};
},
),
}),
});

View File

@ -1,50 +1,59 @@
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; import { Elysia, t } from "elysia";
import { adminStore } from "../app/adminStore.ts";
import { bot } from "../bot/mod.ts"; import { bot } from "../bot/mod.ts";
import { getUser } from "./withUser.ts"; import { getUser } from "./withUser.ts";
import { adminStore } from "../app/adminStore.ts";
export const usersRoute = createPathFilter({ export const usersRoute = new Elysia()
"{userId}/photo": createMethodFilter({ .get(
GET: createEndpoint( "/:userId/photo",
{ query: null, body: null }, async ({ params }) => {
async ({ params }) => { const user = await getUser(Number(params.userId));
const user = await getUser(Number(params.userId!)); if (!user.photo) {
const photoData = user.photo?.small_file_id throw new Error("User has no photo");
? await fetch( }
`https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile( const photoFile = await bot.api.getFile(user.photo.small_file_id);
user.photo.small_file_id, const photoData = await fetch(
).then((file) => file.file_path)}`, `https://api.telegram.org/file/bot${bot.token}/${photoFile.file_path}`,
).then((resp) => resp.arrayBuffer()) ).then((resp) => {
: undefined; if (!resp.ok) {
if (!photoData) { throw new Error("Failed to fetch photo");
return { status: 404, body: { type: "text/plain", data: "User has no photo" } };
} }
return { return resp;
status: 200, }).then((resp) => resp.arrayBuffer());
body: {
type: "image/jpeg",
data: new Blob([photoData], { type: "image/jpeg" }),
},
};
},
),
}),
"{userId}": createMethodFilter({ return new Response(new File([photoData], "avatar.jpg", { type: "image/jpeg" }));
GET: createEndpoint( },
{ query: null, body: null }, {
async ({ params }) => { params: t.Object({ userId: t.String() }),
const user = await getUser(Number(params.userId!)); },
const [adminEntry] = await adminStore.getBy("tgUserId", { value: user.id }); )
const admin = adminEntry?.value; .get(
return { "/:userId",
status: 200, async ({ params }) => {
body: { const user = await getUser(Number(params.userId));
type: "application/json", const [adminEntry] = await adminStore.getBy("tgUserId", { value: user.id });
data: { ...user, admin }, const admin = adminEntry?.value;
}, return {
}; id: user.id,
}, first_name: user.first_name,
), last_name: user.last_name ?? null,
}), username: user.username ?? null,
}); bio: user.bio ?? null,
admin: admin ?? null,
};
},
{
params: t.Object({ userId: t.String() }),
response: t.Object({
id: t.Number(),
first_name: t.String(),
last_name: t.Nullable(t.String()),
username: t.Nullable(t.String()),
bio: t.Nullable(t.String()),
admin: t.Nullable(t.Object({
tgUserId: t.Number(),
promotedBy: t.Nullable(t.String()),
})),
}),
},
);

View File

@ -1,34 +1,33 @@
import { Chat } from "grammy_types"; import { Chat } from "grammy_types";
import { Model } from "indexed_kv"; import { Model } from "indexed_kv";
import { Output } from "t_rest/client";
import { Admin, adminStore } from "../app/adminStore.ts"; import { Admin, adminStore } from "../app/adminStore.ts";
import { bot } from "../bot/mod.ts"; import { bot } from "../bot/mod.ts";
import { sessions } from "./sessionsRoute.ts"; import { sessions } from "./sessionsRoute.ts";
export async function withUser<O extends Output>( export async function withUser<O>(
query: { sessionId: string }, query: { sessionId: string },
cb: (user: Chat.PrivateGetChat) => Promise<O>, cb: (user: Chat.PrivateGetChat) => Promise<O>,
) { ) {
const session = sessions.get(query.sessionId); const session = sessions.get(query.sessionId);
if (!session?.userId) { if (!session?.userId) {
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const; throw new Error("Must be logged in");
} }
const user = await getUser(session.userId); const user = await getUser(session.userId);
return cb(user); return cb(user);
} }
export async function withAdmin<O extends Output>( export async function withAdmin<O>(
query: { sessionId: string }, query: { sessionId: string },
cb: (user: Chat.PrivateGetChat, admin: Model<Admin>) => Promise<O>, cb: (user: Chat.PrivateGetChat, admin: Model<Admin>) => Promise<O>,
) { ) {
const session = sessions.get(query.sessionId); const session = sessions.get(query.sessionId);
if (!session?.userId) { if (!session?.userId) {
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const; throw new Error("Must be logged in");
} }
const user = await getUser(session.userId); const user = await getUser(session.userId);
const [admin] = await adminStore.getBy("tgUserId", { value: session.userId }); const [admin] = await adminStore.getBy("tgUserId", { value: session.userId });
if (!admin) { if (!admin) {
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const; throw new Error("Must be admin");
} }
return cb(user, admin); return cb(user, admin);
} }

View File

@ -2,21 +2,40 @@ import { subMinutes } from "date-fns";
import { Model } from "indexed_kv"; import { Model } from "indexed_kv";
import createOpenApiFetch from "openapi_fetch"; import createOpenApiFetch from "openapi_fetch";
import { info } from "std/log/mod.ts"; import { info } from "std/log/mod.ts";
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; import { Elysia, t } from "elysia";
import { activeGenerationWorkers } from "../app/generationQueue.ts"; import { activeGenerationWorkers } from "../app/generationQueue.ts";
import { generationStore } from "../app/generationStore.ts"; import { generationStore } from "../app/generationStore.ts";
import * as SdApi from "../app/sdApi.ts"; import * as SdApi from "../app/sdApi.ts";
import { import {
WorkerInstance, WorkerInstance,
workerInstanceSchema, workerInstanceSchema as _,
workerInstanceStore, workerInstanceStore,
} from "../app/workerInstanceStore.ts"; } from "../app/workerInstanceStore.ts";
import { getAuthHeader } from "../utils/getAuthHeader.ts"; import { getAuthHeader } from "../utils/getAuthHeader.ts";
import { omitUndef } from "../utils/omitUndef.ts"; import { omitUndef } from "../utils/omitUndef.ts";
import { withAdmin } from "./withUser.ts"; import { withAdmin } from "./withUser.ts";
export type WorkerData = Omit<WorkerInstance, "sdUrl" | "sdAuth"> & { const workerInstanceSchema = t.Object({
name: t.Nullable(t.String()),
key: t.String(),
sdUrl: t.String(),
sdAuth: t.Nullable(t.Object({
user: t.String(),
password: t.String(),
})),
lastOnlineTime: t.Number(),
lastError: t.Optional(t.Object({
message: t.String(),
time: t.Number(),
})),
});
export type WorkerData = {
id: string; id: string;
name: string | null;
key: string;
lastError?: { message: string; time: number };
lastOnlineTime: number | null;
isActive: boolean; isActive: boolean;
imagesPerMinute: number; imagesPerMinute: number;
stepsPerMinute: number; stepsPerMinute: number;
@ -24,6 +43,22 @@ export type WorkerData = Omit<WorkerInstance, "sdUrl" | "sdAuth"> & {
pixelStepsPerMinute: number; pixelStepsPerMinute: number;
}; };
const workerDataSchema = t.Object({
id: t.String(),
key: t.String(),
name: t.Nullable(t.String()),
lastError: t.Optional(t.Object({
message: t.String(),
time: t.Number(),
})),
lastOnlineTime: t.Nullable(t.Number()),
isActive: t.Boolean(),
imagesPerMinute: t.Number(),
stepsPerMinute: t.Number(),
pixelsPerMinute: t.Number(),
pixelStepsPerMinute: t.Number(),
});
const STATS_INTERVAL_MIN = 10; const STATS_INTERVAL_MIN = 10;
async function getWorkerData(workerInstance: Model<WorkerInstance>): Promise<WorkerData> { async function getWorkerData(workerInstance: Model<WorkerInstance>): Promise<WorkerData> {
@ -51,189 +86,172 @@ async function getWorkerData(workerInstance: Model<WorkerInstance>): Promise<Wor
) )
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN; .reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
return { return omitUndef({
id: workerInstance.id, id: workerInstance.id,
key: workerInstance.value.key, key: workerInstance.value.key,
name: workerInstance.value.name, name: workerInstance.value.name,
lastError: workerInstance.value.lastError, lastError: workerInstance.value.lastError,
lastOnlineTime: workerInstance.value.lastOnlineTime, lastOnlineTime: workerInstance.value.lastOnlineTime ?? null,
isActive: activeGenerationWorkers.get(workerInstance.id)?.isProcessing ?? false, isActive: activeGenerationWorkers.get(workerInstance.id)?.isProcessing ?? false,
imagesPerMinute, imagesPerMinute,
stepsPerMinute, stepsPerMinute,
pixelsPerMinute, pixelsPerMinute,
pixelStepsPerMinute, pixelStepsPerMinute,
}; });
} }
export const workersRoute = createPathFilter({ export const workersRoute = new Elysia()
"": createMethodFilter({ .get(
GET: createEndpoint( "",
{ query: null, body: null }, async () => {
async () => { const workerInstances = await workerInstanceStore.getAll();
const workerInstances = await workerInstanceStore.getAll(); const workers = await Promise.all(workerInstances.map(getWorkerData));
const workers = await Promise.all(workerInstances.map(getWorkerData)); return workers;
return { },
status: 200, {
body: { type: "application/json", data: workers satisfies WorkerData[] }, response: t.Array(workerDataSchema),
}; },
}, )
), .post(
POST: createEndpoint( "",
{ async ({ query, body }) => {
query: { return withAdmin(query, async (user) => {
sessionId: { type: "string" }, const workerInstance = await workerInstanceStore.create(body);
}, info(`User ${user.first_name} created worker ${workerInstance.value.name}`);
body: { return await getWorkerData(workerInstance);
type: "application/json", });
schema: workerInstanceSchema, },
}, {
}, query: t.Object({ sessionId: t.String() }),
async ({ query, body }) => { body: t.Omit(workerInstanceSchema, ["lastOnlineTime", "lastError"]),
return withAdmin(query, async (user) => { },
const workerInstance = await workerInstanceStore.create(body.data); )
info(`User ${user.first_name} created worker ${workerInstance.value.name}`); .get(
return { "/:workerId",
status: 200, async ({ params }) => {
body: { type: "application/json", data: await getWorkerData(workerInstance) }, const workerInstance = await workerInstanceStore.getById(params.workerId!);
}; if (!workerInstance) {
}); throw new Error("Worker not found");
}, }
), return await getWorkerData(workerInstance);
}), },
{
"{workerId}": createPathFilter({ params: t.Object({ workerId: t.String() }),
"": createMethodFilter({ response: workerDataSchema,
GET: createEndpoint( },
{ query: null, body: null }, )
async ({ params }) => { .patch(
const workerInstance = await workerInstanceStore.getById(params.workerId!); "/:workerId",
if (!workerInstance) { async ({ params, query, body }) => {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; const workerInstance = await workerInstanceStore.getById(params.workerId!);
} if (!workerInstance) {
return { throw new Error("Worker not found");
status: 200, }
body: { type: "application/json", data: await getWorkerData(workerInstance) }, return withAdmin(query, async (user) => {
}; info(
}, `User ${user.first_name} updated worker ${workerInstance.value.name}: ${
JSON.stringify(body)
}`,
);
await workerInstance.update(omitUndef(body));
return await getWorkerData(workerInstance);
});
},
{
params: t.Object({ workerId: t.String() }),
query: t.Object({ sessionId: t.String() }),
body: t.Partial(workerInstanceSchema),
},
)
.delete(
"/:workerId",
async ({ params, query }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
throw new Error("Worker not found");
}
return withAdmin(query, async (user) => {
info(`User ${user.first_name} deleted worker ${workerInstance.value.name}`);
await workerInstance.delete();
return null;
});
},
{
params: t.Object({ workerId: t.String() }),
query: t.Object({ sessionId: t.String() }),
response: t.Null(),
},
)
.get(
"/:workerId/loras",
async ({ params }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
throw new Error("Worker not found");
}
const sdClient = createOpenApiFetch<SdApi.paths>({
baseUrl: workerInstance.value.sdUrl,
headers: getAuthHeader(workerInstance.value.sdAuth),
});
const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {});
if (lorasResponse.error) {
throw new Error(
`Loras request failed: ${lorasResponse["error"]}`,
);
}
const loras = (lorasResponse.data as Lora[]).map((lora) => ({
name: lora.name,
alias: lora.alias ?? null,
}));
return loras;
},
{
params: t.Object({ workerId: t.String() }),
response: t.Array(
t.Object({
name: t.String(),
alias: t.Nullable(t.String()),
}),
), ),
PATCH: createEndpoint( },
{ )
query: { .get(
sessionId: { type: "string" }, "/:workerId/models",
}, async ({ params }) => {
body: { const workerInstance = await workerInstanceStore.getById(params.workerId!);
type: "application/json", if (!workerInstance) {
schema: { ...workerInstanceSchema, required: [] }, throw new Error("Worker not found");
}, }
}, const sdClient = createOpenApiFetch<SdApi.paths>({
async ({ params, query, body }) => { baseUrl: workerInstance.value.sdUrl,
const workerInstance = await workerInstanceStore.getById(params.workerId!); headers: getAuthHeader(workerInstance.value.sdAuth),
if (!workerInstance) { });
return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {});
} if (modelsResponse.error) {
return withAdmin(query, async (user) => { throw new Error(
info( `Models request failed: ${modelsResponse["error"]}`,
`User ${user.first_name} updated worker ${workerInstance.value.name}: ${ );
JSON.stringify(body.data) }
}`, const models = modelsResponse.data.map((model) => ({
); title: model.title,
await workerInstance.update(omitUndef(body.data)); modelName: model.model_name,
return { hash: model.hash ?? null,
status: 200, sha256: model.sha256 ?? null,
body: { type: "application/json", data: await getWorkerData(workerInstance) }, }));
}; return models;
}); },
}, {
params: t.Object({ workerId: t.String() }),
response: t.Array(
t.Object({
title: t.String(),
modelName: t.String(),
hash: t.Nullable(t.String()),
sha256: t.Nullable(t.String()),
}),
), ),
DELETE: createEndpoint( },
{ );
query: {
sessionId: { type: "string" },
},
body: null,
},
async ({ params, query }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
}
return withAdmin(query, async (user) => {
info(`User ${user.first_name} deleted worker ${workerInstance.value.name}`);
await workerInstance.delete();
return { status: 200, body: { type: "application/json", data: null } };
});
},
),
}),
"loras": createMethodFilter({
GET: createEndpoint(
{ query: null, body: null },
async ({ params }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
}
const sdClient = createOpenApiFetch<SdApi.paths>({
baseUrl: workerInstance.value.sdUrl,
headers: getAuthHeader(workerInstance.value.sdAuth),
});
const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {});
if (lorasResponse.error) {
return {
status: 500,
body: { type: "text/plain", data: `Loras request failed: ${lorasResponse["error"]}` },
};
}
const loras = (lorasResponse.data as Lora[]).map((lora) => ({
name: lora.name,
alias: lora.alias ?? null,
}));
return {
status: 200,
body: { type: "application/json", data: loras },
};
},
),
}),
"models": createMethodFilter({
GET: createEndpoint(
{ query: null, body: null },
async ({ params }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
}
const sdClient = createOpenApiFetch<SdApi.paths>({
baseUrl: workerInstance.value.sdUrl,
headers: getAuthHeader(workerInstance.value.sdAuth),
});
const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {});
if (modelsResponse.error) {
return {
status: 500,
body: {
type: "text/plain",
data: `Models request failed: ${modelsResponse["error"]}`,
},
};
}
const models = modelsResponse.data.map((model) => ({
title: model.title,
modelName: model.model_name,
hash: model.hash,
sha256: model.sha256,
}));
return {
status: 200,
body: { type: "application/json", data: models },
};
},
),
}),
}),
});
export interface Lora { export interface Lora {
name: string; name: string;

View File

@ -11,6 +11,9 @@
"async": "https://deno.land/x/async@v2.0.2/mod.ts", "async": "https://deno.land/x/async@v2.0.2/mod.ts",
"date-fns": "https://cdn.skypack.dev/date-fns@2.30.0?dts", "date-fns": "https://cdn.skypack.dev/date-fns@2.30.0?dts",
"date-fns/utc": "https://cdn.skypack.dev/@date-fns/utc@1.1.0?dts", "date-fns/utc": "https://cdn.skypack.dev/@date-fns/utc@1.1.0?dts",
"elysia": "https://esm.sh/elysia@0.7.21?dev",
"elysia/eden": "https://esm.sh/@elysiajs/eden@0.7.4?dev",
"elysia/swagger": "https://esm.sh/@elysiajs/swagger@0.7.4?dev",
"exifreader": "https://esm.sh/exifreader@4.14.1", "exifreader": "https://esm.sh/exifreader@4.14.1",
"file_type": "https://esm.sh/file-type@18.5.0", "file_type": "https://esm.sh/file-type@18.5.0",
"grammy": "https://lib.deno.dev/x/grammy@1/mod.ts", "grammy": "https://lib.deno.dev/x/grammy@1/mod.ts",

View File

@ -299,10 +299,12 @@
"https://deno.land/x/swc@0.2.1/lib/deno_swc.generated.js": "a112eb5a4871bcbd5b660d3fd9d7afdd5cf2bb9e135eb9b04aceaa24d16481a0", "https://deno.land/x/swc@0.2.1/lib/deno_swc.generated.js": "a112eb5a4871bcbd5b660d3fd9d7afdd5cf2bb9e135eb9b04aceaa24d16481a0",
"https://deno.land/x/swc@0.2.1/mod.ts": "9e03f5daf4c2a25208e34e19ce3e997d2e23a5a11ee8c8ed58023b0e3626073f", "https://deno.land/x/swc@0.2.1/mod.ts": "9e03f5daf4c2a25208e34e19ce3e997d2e23a5a11ee8c8ed58023b0e3626073f",
"https://deno.land/x/ulid@v0.3.0/mod.ts": "f7ff065b66abd485051fc68af23becef6ccc7e81f7774d7fcfd894a4b2da1984", "https://deno.land/x/ulid@v0.3.0/mod.ts": "f7ff065b66abd485051fc68af23becef6ccc7e81f7774d7fcfd894a4b2da1984",
"https://esm.sh/@elysiajs/swagger@0.7.4?dev": "78520881a756f6c3a69ccc1f306bc32e258c6e0e27f971f68231763fbafd303e",
"https://esm.sh/@grammyjs/types@2.0.0": "5e5d1ae9f014cb64f0af36fb91f9d35414d670049b77367818a70cd62092b2ac", "https://esm.sh/@grammyjs/types@2.0.0": "5e5d1ae9f014cb64f0af36fb91f9d35414d670049b77367818a70cd62092b2ac",
"https://esm.sh/@grammyjs/types@2.12.1": "eebe448d3bf3d4fdaeacee50e31b9e3947ce965d2591f1036e5c0273cb7aec36", "https://esm.sh/@grammyjs/types@2.12.1": "eebe448d3bf3d4fdaeacee50e31b9e3947ce965d2591f1036e5c0273cb7aec36",
"https://esm.sh/@twind/core@1.1.3": "bf3e64c13de95b061a721831bf2aed322f6e7335848b06d805168c3212686c1d", "https://esm.sh/@twind/core@1.1.3": "bf3e64c13de95b061a721831bf2aed322f6e7335848b06d805168c3212686c1d",
"https://esm.sh/@twind/preset-tailwind@1.1.4": "29f5e4774c344ff08d2636e3d86382060a8b40d6bce1c158b803df1823c2804c", "https://esm.sh/@twind/preset-tailwind@1.1.4": "29f5e4774c344ff08d2636e3d86382060a8b40d6bce1c158b803df1823c2804c",
"https://esm.sh/elysia@0.7.21?dev": "6e954350858d5f2fd6c8e61dadf8d9989186f436ccdeb850606359d5e163298c",
"https://esm.sh/exifreader@4.14.1": "d0f21973393b0d1a6ed329dac8fcfb2f87ce47fe40b8172e205e7d6d85790bb6", "https://esm.sh/exifreader@4.14.1": "d0f21973393b0d1a6ed329dac8fcfb2f87ce47fe40b8172e205e7d6d85790bb6",
"https://esm.sh/file-type@18.5.0": "f01f6eddb05d365925545e26bd449f934dc042bead882b2795c35c4f48d690a3", "https://esm.sh/file-type@18.5.0": "f01f6eddb05d365925545e26bd449f934dc042bead882b2795c35c4f48d690a3",
"https://esm.sh/openapi-fetch@0.7.6": "43eff6df93e773801cb6ade02b0f47c05d0bfe2fb044adbf2b94fa74ff30d35b", "https://esm.sh/openapi-fetch@0.7.6": "43eff6df93e773801cb6ade02b0f47c05d0bfe2fb044adbf2b94fa74ff30d35b",
@ -321,6 +323,7 @@
"https://esm.sh/use-local-storage@3.0.0?external=react&dev": "4cf7fce754a9488940daa76051389421c6341420cae5b8c7d2401158ffe79ec0", "https://esm.sh/use-local-storage@3.0.0?external=react&dev": "4cf7fce754a9488940daa76051389421c6341420cae5b8c7d2401158ffe79ec0",
"https://esm.sh/v132/@twind/core@1.1.3/denonext/core.mjs": "c2618087a5d5cc406c7dc1079015f4d7cc874bee167f74e9945694896d907b6d", "https://esm.sh/v132/@twind/core@1.1.3/denonext/core.mjs": "c2618087a5d5cc406c7dc1079015f4d7cc874bee167f74e9945694896d907b6d",
"https://esm.sh/v132/@twind/preset-tailwind@1.1.4/denonext/preset-tailwind.mjs": "3cb9f5cde89e11cd2adad54ff264f62f5000ccb1694cd88874b1856eb2d8d7f7", "https://esm.sh/v132/@twind/preset-tailwind@1.1.4/denonext/preset-tailwind.mjs": "3cb9f5cde89e11cd2adad54ff264f62f5000ccb1694cd88874b1856eb2d8d7f7",
"https://esm.sh/v133/@elysiajs/swagger@0.7.4/denonext/swagger.development.mjs": "63cc67eee29123918283027744da23009fc3767088c513f8bfbfef945b7170d1",
"https://esm.sh/v133/@formatjs/ecma402-abstract@1.17.2/X-ZS9yZWFjdA/denonext/ecma402-abstract.development.mjs": "178a303e29d73369b4c7c1da5a18393c2baa8742a0e8a45be85f506c17f763d9", "https://esm.sh/v133/@formatjs/ecma402-abstract@1.17.2/X-ZS9yZWFjdA/denonext/ecma402-abstract.development.mjs": "178a303e29d73369b4c7c1da5a18393c2baa8742a0e8a45be85f506c17f763d9",
"https://esm.sh/v133/@formatjs/fast-memoize@2.2.0/X-ZS9yZWFjdA/denonext/fast-memoize.development.mjs": "4c027b3308490b65dc899f683b1ff9be8da4b7a2e1e32433ef03bcb8f0fdf821", "https://esm.sh/v133/@formatjs/fast-memoize@2.2.0/X-ZS9yZWFjdA/denonext/fast-memoize.development.mjs": "4c027b3308490b65dc899f683b1ff9be8da4b7a2e1e32433ef03bcb8f0fdf821",
"https://esm.sh/v133/@formatjs/icu-messageformat-parser@2.6.2/X-ZS9yZWFjdA/denonext/icu-messageformat-parser.development.mjs": "68c7a9be44aaa3e35bfe18668e17a2d4a465e11f59f3a1e025ee83fe1bd0b971", "https://esm.sh/v133/@formatjs/icu-messageformat-parser@2.6.2/X-ZS9yZWFjdA/denonext/icu-messageformat-parser.development.mjs": "68c7a9be44aaa3e35bfe18668e17a2d4a465e11f59f3a1e025ee83fe1bd0b971",
@ -330,14 +333,25 @@
"https://esm.sh/v133/@grammyjs/types@2.0.0/denonext/types.mjs": "7ee61bd0c55a152ea1ffaf3fbe63fce6c103ae836265b23290284d6ba0e3bc5b", "https://esm.sh/v133/@grammyjs/types@2.0.0/denonext/types.mjs": "7ee61bd0c55a152ea1ffaf3fbe63fce6c103ae836265b23290284d6ba0e3bc5b",
"https://esm.sh/v133/@grammyjs/types@2.12.1/denonext/types.mjs": "3636f7a1ca7fef89fa735d832b72193a834bc7f5250b6bf182544be53a6ab218", "https://esm.sh/v133/@grammyjs/types@2.12.1/denonext/types.mjs": "3636f7a1ca7fef89fa735d832b72193a834bc7f5250b6bf182544be53a6ab218",
"https://esm.sh/v133/@remix-run/router@1.9.0/X-ZS9yZWFjdA/denonext/router.development.mjs": "97f96f031a7298b0afbbadb23177559ee9b915314d7adf0b9c6a8d7f452c70e7", "https://esm.sh/v133/@remix-run/router@1.9.0/X-ZS9yZWFjdA/denonext/router.development.mjs": "97f96f031a7298b0afbbadb23177559ee9b915314d7adf0b9c6a8d7f452c70e7",
"https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/compiler.development.js": "94129b8b2f26f79bd1ec51f479776e7cdebe521f60bd2eae695634765959f7b9",
"https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/system.development.js": "e2c96ea22f15b9e79e8021b65ae62bfd1b4fd94176dd9c8fc1042fa014c64e9e",
"https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/typebox.development.mjs": "d5776065180eea03471f3f2c992a0cf8289219881c40b68270f543536f5ee029",
"https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/value.development.js": "41db30d8ba66c836ad5faf931f7ce38d15f943339847b8a7491014f8fe2be451",
"https://esm.sh/v133/client-only@0.0.1/X-ZS9yZWFjdA/denonext/client-only.development.mjs": "b7efaef2653f7f628d084e24a4d5a857c9cd1a5e5060d1d9957e185ee98c8a28", "https://esm.sh/v133/client-only@0.0.1/X-ZS9yZWFjdA/denonext/client-only.development.mjs": "b7efaef2653f7f628d084e24a4d5a857c9cd1a5e5060d1d9957e185ee98c8a28",
"https://esm.sh/v133/cookie@0.5.0/denonext/cookie.development.mjs": "04344caac1f8341ced6ec11d15b95a36dbda70a8ebb6a809b20b5912a5de2b8b",
"https://esm.sh/v133/crc-32@0.3.0/denonext/crc-32.mjs": "92bbd96cd5a92e45267cf4b3d3928b9355d16963da1ba740637fb10f1daca590", "https://esm.sh/v133/crc-32@0.3.0/denonext/crc-32.mjs": "92bbd96cd5a92e45267cf4b3d3928b9355d16963da1ba740637fb10f1daca590",
"https://esm.sh/v133/elysia@0.7.21/denonext/elysia.development.mjs": "4bf283f62b935737d33ab4e3e59195ba9cb0ea2fc2bac90c8e9fa00a44b5e575",
"https://esm.sh/v133/eventemitter3@5.0.1/denonext/eventemitter3.development.mjs": "e19a3e8d30d7564ce7b394636fc66ef273c6394bb6c5820b9a9bfba7ec4ac2dd",
"https://esm.sh/v133/exifreader@4.14.1/denonext/exifreader.mjs": "691e1c1d1337ccaf092bf39115fdac56bf69e93259d04bb03985e918202317ab", "https://esm.sh/v133/exifreader@4.14.1/denonext/exifreader.mjs": "691e1c1d1337ccaf092bf39115fdac56bf69e93259d04bb03985e918202317ab",
"https://esm.sh/v133/fast-decode-uri-component@1.0.1/denonext/fast-decode-uri-component.development.mjs": "004912e1f391fccf376cf58ff1ab06d6d4ed241cab9c7bed23756091bedbdc36",
"https://esm.sh/v133/fast-querystring@1.1.2/denonext/fast-querystring.development.mjs": "da06ef49d7e834dbac2b3b189de02c80e71cd5942b339de0011ab84aae0de0ff",
"https://esm.sh/v133/file-type@18.5.0/denonext/file-type.mjs": "785cac1bc363448647871e1b310422e01c9cfb7b817a68690710b786d3598311", "https://esm.sh/v133/file-type@18.5.0/denonext/file-type.mjs": "785cac1bc363448647871e1b310422e01c9cfb7b817a68690710b786d3598311",
"https://esm.sh/v133/hoist-non-react-statics@3.3.2/X-ZS9yZWFjdA/denonext/hoist-non-react-statics.development.mjs": "fcafac9e3c33810f18ecb43dfc32ce80efc88adc63d3f49fd7ada0665d2146c6", "https://esm.sh/v133/hoist-non-react-statics@3.3.2/X-ZS9yZWFjdA/denonext/hoist-non-react-statics.development.mjs": "fcafac9e3c33810f18ecb43dfc32ce80efc88adc63d3f49fd7ada0665d2146c6",
"https://esm.sh/v133/ieee754@1.2.1/denonext/ieee754.mjs": "9ec2806065f50afcd4cf3f3f2f38d93e777a92a5954dda00d219f57d4b24832f", "https://esm.sh/v133/ieee754@1.2.1/denonext/ieee754.mjs": "9ec2806065f50afcd4cf3f3f2f38d93e777a92a5954dda00d219f57d4b24832f",
"https://esm.sh/v133/inherits@2.0.4/denonext/inherits.mjs": "8095f3d6aea060c904fb24ae50f2882779c0acbe5d56814514c8b5153f3b4b3b", "https://esm.sh/v133/inherits@2.0.4/denonext/inherits.mjs": "8095f3d6aea060c904fb24ae50f2882779c0acbe5d56814514c8b5153f3b4b3b",
"https://esm.sh/v133/intl-messageformat@10.5.3/X-ZS9yZWFjdA/denonext/intl-messageformat.development.mjs": "d6b701c562c51ec4b4e1deb641c6623986abe304290bbb291e6404cb6a31dc41", "https://esm.sh/v133/intl-messageformat@10.5.3/X-ZS9yZWFjdA/denonext/intl-messageformat.development.mjs": "d6b701c562c51ec4b4e1deb641c6623986abe304290bbb291e6404cb6a31dc41",
"https://esm.sh/v133/lodash.clonedeep@4.5.0/denonext/lodash.clonedeep.development.mjs": "f44c863cb41bcd2714103a36d97e8eb8268f56070b5cd56dcfae26c556e6622e",
"https://esm.sh/v133/memoirist@0.1.4/denonext/memoirist.development.mjs": "e19f8379a684345ebcab9a688b0b188f29120f4bf1122588b779113cd1ec0e4e",
"https://esm.sh/v133/openapi-fetch@0.7.6/denonext/openapi-fetch.mjs": "1ec8ed23c9141c7f4e58de06f84525e310fe7dda1aeaf675c8edafb3d8292cfc", "https://esm.sh/v133/openapi-fetch@0.7.6/denonext/openapi-fetch.mjs": "1ec8ed23c9141c7f4e58de06f84525e310fe7dda1aeaf675c8edafb3d8292cfc",
"https://esm.sh/v133/peek-readable@5.0.0/denonext/peek-readable.mjs": "5e799ea86e9c501873f687eda9c891a75ed55ba666b5dd822eaa3d28a8a5f2b1", "https://esm.sh/v133/peek-readable@5.0.0/denonext/peek-readable.mjs": "5e799ea86e9c501873f687eda9c891a75ed55ba666b5dd822eaa3d28a8a5f2b1",
"https://esm.sh/v133/png-chunk-text@1.0.0/denonext/png-chunk-text.mjs": "e8bb89595ceab2603531693319da08a9dd90d51169437de47a73cf8bac7baa11", "https://esm.sh/v133/png-chunk-text@1.0.0/denonext/png-chunk-text.mjs": "e8bb89595ceab2603531693319da08a9dd90d51169437de47a73cf8bac7baa11",

View File

@ -11,18 +11,18 @@ export function AdminsPage(props: { sessionId: string | null }) {
const addDialogRef = useRef<HTMLDialogElement>(null); const addDialogRef = useRef<HTMLDialogElement>(null);
const getSession = useSWR( const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getUser = useSWR( const getUser = useSWR(
getSession.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const ? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getAdmins = useSWR( const getAdmins = useSWR(
["admins", "GET", {}] as const, ["/admins", { method: "GET" }] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
@ -35,7 +35,8 @@ export function AdminsPage(props: { sessionId: string | null }) {
mutate( mutate(
(key) => Array.isArray(key) && key[0] === "admins", (key) => Array.isArray(key) && key[0] === "admins",
async () => async () =>
fetchApi("admins/promote_self", "POST", { fetchApi("/admins/promote_self", {
method: "POST",
query: { sessionId: sessionId ?? "" }, query: { sessionId: sessionId ?? "" },
}).then(handleResponse), }).then(handleResponse),
{ populateCache: false }, { populateCache: false },
@ -102,13 +103,11 @@ function AddAdminDialog(props: {
mutate( mutate(
(key) => Array.isArray(key) && key[0] === "admins", (key) => Array.isArray(key) && key[0] === "admins",
async () => async () =>
fetchApi("admins", "POST", { fetchApi("/admins", {
method: "POST",
query: { sessionId: sessionId! }, query: { sessionId: sessionId! },
body: { body: {
type: "application/json", tgUserId: Number(data.get("tgUserId") as string),
data: {
tgUserId: Number(data.get("tgUserId") as string),
},
}, },
}).then(handleResponse), }).then(handleResponse),
{ populateCache: false }, { populateCache: false },
@ -152,17 +151,17 @@ function AdminListItem(props: { admin: AdminData; sessionId: string | null }) {
const deleteDialogRef = useRef<HTMLDialogElement>(null); const deleteDialogRef = useRef<HTMLDialogElement>(null);
const getAdminUser = useSWR( const getAdminUser = useSWR(
["users/{userId}", "GET", { params: { userId: String(admin.tgUserId) } }] as const, ["/users/:userId", { params: { userId: String( admin.tgUserId) } }] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getSession = useSWR( const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getUser = useSWR( const getUser = useSWR(
getSession.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const ? ["/users/:userId", { params: { userId: String( getSession.data.userId) } }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
@ -226,7 +225,8 @@ function DeleteAdminDialog(props: {
mutate( mutate(
(key) => Array.isArray(key) && key[0] === "admins", (key) => Array.isArray(key) && key[0] === "admins",
async () => async () =>
fetchApi("admins/{adminId}", "DELETE", { fetchApi("/admins/:adminId", {
method: "DELETE",
query: { sessionId: sessionId! }, query: { sessionId: sessionId! },
params: { adminId: adminId }, params: { adminId: adminId },
}).then(handleResponse), }).then(handleResponse),

View File

@ -16,7 +16,7 @@ export function App() {
// initialize a new session when there is no session ID // initialize a new session when there is no session ID
useEffect(() => { useEffect(() => {
if (!sessionId) { if (!sessionId) {
fetchApi("sessions", "POST", {}).then(handleResponse).then((session) => { fetchApi("/sessions", { method: "POST" }).then(handleResponse).then((session) => {
console.log("Initialized session", session.id); console.log("Initialized session", session.id);
setSessionId(session.id); setSessionId(session.id);
}); });

View File

@ -23,32 +23,45 @@ export function AppHeader(props: {
const { className, sessionId, onLogOut } = props; const { className, sessionId, onLogOut } = props;
const getSession = useSWR( const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["/sessions/:sessionId", { method: "GET", params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
{ onError: () => onLogOut() }, { onError: () => onLogOut() },
); );
const getUser = useSWR( const getUser = useSWR(
getSession.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const ? ["/users/:userId", {
method: "GET",
params: { userId: String(getSession.data.userId) },
}] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getBot = useSWR( const getBot = useSWR(
["bot", "GET", {}] as const, ["/bot", { method: "GET" }] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getUserPhoto = useSWR( const getUserPhoto = useSWR(
getSession.data?.userId getSession.data?.userId
? ["users/{userId}/photo", "GET", { ? ["/users/:userId/photo", {
method: "GET",
params: { userId: String(getSession.data.userId) }, params: { userId: String(getSession.data.userId) },
}] as const }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)), (args) =>
fetchApi(...args).then((response) => response.response)
.then((response) => {
if (!response.ok) throw new Error(response.statusText);
return response;
})
.then((response) => response.blob())
.then((blob) => blob ? URL.createObjectURL(blob) : null),
); );
console.log(getUserPhoto);
return ( return (
<header <header
className={cx( className={cx(

View File

@ -7,7 +7,7 @@ import { fetchApi, handleResponse } from "./apiClient.ts";
export function QueuePage() { export function QueuePage() {
const getJobs = useSWR( const getJobs = useSWR(
["jobs", "GET", {}] as const, ["/jobs", {}] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
{ refreshInterval: 2000 }, { refreshInterval: 2000 },
); );
@ -25,10 +25,10 @@ export function QueuePage() {
getJobs.data?.map((job) => ( getJobs.data?.map((job) => (
<li <li
className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-md" className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-md"
key={job.id.join("/")} key={job.id}
> >
<span className="">{job.place}.</span> <span className="">{job.place}.</span>
<span>{getFlagEmoji(job.state.from.language_code)}</span> <span>{getFlagEmoji(job.state.from.language_code ?? undefined)}</span>
<strong>{job.state.from.first_name} {job.state.from.last_name}</strong> <strong>{job.state.from.first_name} {job.state.from.last_name}</strong>
{job.state.from.username {job.state.from.username
? ( ? (

View File

@ -2,22 +2,23 @@ import React, { useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { cx } from "twind/core"; import { cx } from "twind/core";
import { fetchApi, handleResponse } from "./apiClient.ts"; import { fetchApi, handleResponse } from "./apiClient.ts";
import { omitUndef } from "../utils/omitUndef.ts";
export function SettingsPage(props: { sessionId: string | null }) { export function SettingsPage(props: { sessionId: string | null }) {
const { sessionId } = props; const { sessionId } = props;
const getSession = useSWR( const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getUser = useSWR( const getUser = useSWR(
getSession.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const ? ["/users/:userId", { params: { userId: String( getSession.data.userId) } }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getParams = useSWR( const getParams = useSWR(
["settings/params", "GET", {}] as const, ["/settings/params", {}] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const [newParams, setNewParams] = useState<Partial<typeof getParams.data>>({}); const [newParams, setNewParams] = useState<Partial<typeof getParams.data>>({});
@ -29,9 +30,10 @@ export function SettingsPage(props: { sessionId: string | null }) {
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
getParams.mutate(() => getParams.mutate(() =>
fetchApi("settings/params", "PATCH", { fetchApi("/settings/params", {
method: "PATCH",
query: { sessionId: sessionId ?? "" }, query: { sessionId: sessionId ?? "" },
body: { type: "application/json", data: newParams ?? {} }, body: omitUndef(newParams ?? {}),
}).then(handleResponse) }).then(handleResponse)
) )
.then(() => setNewParams({})) .then(() => setNewParams({}))

View File

@ -5,7 +5,7 @@ import { Counter } from "./Counter.tsx";
export function StatsPage() { export function StatsPage() {
const getGlobalStats = useSWR( const getGlobalStats = useSWR(
["stats", "GET", {}] as const, ["/stats", {}] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
{ refreshInterval: 2_000 }, { refreshInterval: 2_000 },
); );

View File

@ -11,18 +11,18 @@ export function WorkersPage(props: { sessionId: string | null }) {
const addDialogRef = useRef<HTMLDialogElement>(null); const addDialogRef = useRef<HTMLDialogElement>(null);
const getSession = useSWR( const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getUser = useSWR( const getUser = useSWR(
getSession.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const ? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getWorkers = useSWR( const getWorkers = useSWR(
["workers", "GET", {}] as const, ["/workers", { method: "GET" }] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
{ refreshInterval: 5000 }, { refreshInterval: 5000 },
); );
@ -91,16 +91,14 @@ function AddWorkerDialog(props: {
mutate( mutate(
(key) => Array.isArray(key) && key[0] === "workers", (key) => Array.isArray(key) && key[0] === "workers",
async () => async () =>
fetchApi("workers", "POST", { fetchApi("/workers", {
method: "POST",
query: { sessionId: sessionId! }, query: { sessionId: sessionId! },
body: { body: {
type: "application/json", key,
data: { name: name || null,
key, sdUrl,
name: name || null, sdAuth: user && password ? { user, password } : null,
sdUrl,
sdAuth: user && password ? { user, password } : null,
},
}, },
}).then(handleResponse), }).then(handleResponse),
{ populateCache: false }, { populateCache: false },
@ -198,12 +196,12 @@ function WorkerListItem(props: {
const deleteDialogRef = useRef<HTMLDialogElement>(null); const deleteDialogRef = useRef<HTMLDialogElement>(null);
const getSession = useSWR( const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const getUser = useSWR( const getUser = useSWR(
getSession.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const ? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
@ -299,14 +297,12 @@ function EditWorkerDialog(props: {
mutate( mutate(
(key) => Array.isArray(key) && key[0] === "workers", (key) => Array.isArray(key) && key[0] === "workers",
async () => async () =>
fetchApi("workers/{workerId}", "PATCH", { fetchApi("/workers/:workerId", {
method: "PATCH",
params: { workerId: workerId }, params: { workerId: workerId },
query: { sessionId: sessionId! }, query: { sessionId: sessionId! },
body: { body: {
type: "application/json", sdAuth: user && password ? { user, password } : null,
data: {
sdAuth: user && password ? { user, password } : null,
},
}, },
}).then(handleResponse), }).then(handleResponse),
{ populateCache: false }, { populateCache: false },
@ -374,7 +370,8 @@ function DeleteWorkerDialog(props: {
mutate( mutate(
(key) => Array.isArray(key) && key[0] === "workers", (key) => Array.isArray(key) && key[0] === "workers",
async () => async () =>
fetchApi("workers/{workerId}", "DELETE", { fetchApi("/workers/:workerId", {
method: "DELETE",
params: { workerId: workerId }, params: { workerId: workerId },
query: { sessionId: sessionId! }, query: { sessionId: sessionId! },
}).then(handleResponse), }).then(handleResponse),

View File

@ -1,15 +1,13 @@
import { createFetcher, Output } from "t_rest/client"; import { edenFetch } from "elysia/eden";
import { ApiHandler } from "../api/serveApi.ts"; import { Api } from "../api/serveApi.ts";
export const fetchApi = createFetcher<ApiHandler>({ export const fetchApi = edenFetch<Api>(`${location.origin}/api`);
baseUrl: `${location.origin}/api/`,
});
export function handleResponse<T extends Output>( export function handleResponse<D, E>(
response: T, response: { data: D; error: E },
): (T & { status: 200 })["body"]["data"] { ): NonNullable<D> {
if (response.status !== 200) { if (response.data) {
throw new Error(String(response.body.data)); return response.data;
} }
return response.body.data; throw new Error(String(response.error));
} }