forked from pinks/eris
Compare commits
1 Commits
main
...
send-origi
Author | SHA1 | Date |
---|---|---|
nameless | 8b5d242865 |
|
@ -1,128 +1,129 @@
|
||||||
|
import { Model } from "indexed_kv";
|
||||||
import { info } from "std/log/mod.ts";
|
import { info } from "std/log/mod.ts";
|
||||||
import { Elysia, Static, t } from "elysia";
|
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||||
import { Admin, adminSchema, adminStore } from "../app/adminStore.ts";
|
import { Admin, adminSchema, adminStore } from "../app/adminStore.ts";
|
||||||
import { getUser, withSessionAdmin, withSessionUser } from "./getUser.ts";
|
import { getUser, withAdmin, withUser } from "./withUser.ts";
|
||||||
import { TkvEntry } from "../utils/Tkv.ts";
|
|
||||||
|
|
||||||
const adminDataSchema = t.Intersect([adminSchema, t.Object({ tgUserId: t.Number() })]);
|
export type AdminData = Admin & { id: string };
|
||||||
|
|
||||||
export type AdminData = Static<typeof adminDataSchema>;
|
function getAdminData(adminEntry: Model<Admin>): AdminData {
|
||||||
|
return { id: adminEntry.id, ...adminEntry.value };
|
||||||
function getAdminData(adminEntry: TkvEntry<["admins", number], Admin>): AdminData {
|
|
||||||
return { tgUserId: adminEntry.key[1], ...adminEntry.value };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminsRoute = new Elysia()
|
export const adminsRoute = createPathFilter({
|
||||||
.get(
|
"": createMethodFilter({
|
||||||
"",
|
GET: createEndpoint(
|
||||||
|
{},
|
||||||
async () => {
|
async () => {
|
||||||
const adminEntries = await Array.fromAsync(adminStore.list({ prefix: ["admins"] }));
|
const adminEntries = await adminStore.getAll();
|
||||||
const admins = adminEntries.map(getAdminData);
|
const admins = adminEntries.map(getAdminData);
|
||||||
return admins;
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: admins satisfies AdminData[] },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
POST: createEndpoint(
|
||||||
{
|
{
|
||||||
response: t.Array(adminDataSchema),
|
query: {
|
||||||
|
sessionId: { type: "string" },
|
||||||
},
|
},
|
||||||
)
|
body: {
|
||||||
.post(
|
type: "application/json",
|
||||||
"",
|
schema: {
|
||||||
async ({ query, body, set }) => {
|
type: "object",
|
||||||
return withSessionAdmin({ query, set }, async (sessionUser, sessionAdminEntry) => {
|
properties: {
|
||||||
const newAdminUser = await getUser(body.tgUserId);
|
tgUserId: adminSchema.properties.tgUserId,
|
||||||
const newAdminKey = ["admins", body.tgUserId] as const;
|
},
|
||||||
const newAdminValue = { promotedBy: sessionAdminEntry.key[1] };
|
required: ["tgUserId"],
|
||||||
const newAdminResult = await adminStore.atomicSet(newAdminKey, null, newAdminValue);
|
},
|
||||||
if (!newAdminResult.ok) {
|
},
|
||||||
set.status = 409;
|
},
|
||||||
return "User is already an admin";
|
async ({ query, body }) => {
|
||||||
}
|
return withAdmin(query, async (user, adminEntry) => {
|
||||||
info(`User ${sessionUser.first_name} promoted user ${newAdminUser.first_name} to admin`);
|
const newAdminUser = await getUser(body.data.tgUserId);
|
||||||
return getAdminData({ ...newAdminResult, key: newAdminKey, value: newAdminValue });
|
const newAdminEntry = await adminStore.create({
|
||||||
|
tgUserId: body.data.tgUserId,
|
||||||
|
promotedBy: adminEntry.id,
|
||||||
|
});
|
||||||
|
info(`User ${user.first_name} promoted user ${newAdminUser.first_name} to admin`);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: getAdminData(newAdminEntry) },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
),
|
||||||
query: t.Object({ sessionId: t.String() }),
|
|
||||||
body: t.Object({
|
|
||||||
tgUserId: t.Number(),
|
|
||||||
}),
|
}),
|
||||||
response: {
|
|
||||||
200: adminDataSchema,
|
"promote_self": createMethodFilter({
|
||||||
401: t.Literal("Must be logged in"),
|
POST: createEndpoint(
|
||||||
403: t.Literal("Must be an admin"),
|
{
|
||||||
409: t.Literal("User is already an admin"),
|
query: {
|
||||||
|
sessionId: { type: "string" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/promote_self",
|
|
||||||
// if there are no admins, allow any user to promote themselves
|
// if there are no admins, allow any user to promote themselves
|
||||||
async ({ query, set }) => {
|
async ({ query }) => {
|
||||||
return withSessionUser({ query, set }, async (sessionUser) => {
|
return withUser(query, async (user) => {
|
||||||
const adminEntries = await Array.fromAsync(adminStore.list({ prefix: ["admins"] }));
|
const adminEntries = await adminStore.getAll();
|
||||||
if (adminEntries.length !== 0) {
|
if (adminEntries.length === 0) {
|
||||||
set.status = 409;
|
const newAdminEntry = await adminStore.create({
|
||||||
return "You are not allowed to promote yourself";
|
tgUserId: user.id,
|
||||||
|
promotedBy: null,
|
||||||
|
});
|
||||||
|
info(`User ${user.first_name} promoted themselves to admin`);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: getAdminData(newAdminEntry) },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const newAdminKey = ["admins", sessionUser.id] as const;
|
return {
|
||||||
const newAdminValue = { promotedBy: null };
|
status: 403,
|
||||||
const newAdminResult = await adminStore.set(newAdminKey, newAdminValue);
|
body: { type: "text/plain", data: `You are not allowed to promote yourself` },
|
||||||
info(`User ${sessionUser.first_name} promoted themselves to admin`);
|
};
|
||||||
return getAdminData({ ...newAdminResult, key: newAdminKey, value: newAdminValue });
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
),
|
||||||
query: t.Object({ sessionId: t.String() }),
|
}),
|
||||||
response: {
|
|
||||||
200: adminDataSchema,
|
"{adminId}": createPathFilter({
|
||||||
401: t.Literal("Must be logged in"),
|
"": createMethodFilter({
|
||||||
403: t.Literal("Must be an admin"),
|
GET: createEndpoint(
|
||||||
409: t.Literal("You are not allowed to promote yourself"),
|
{},
|
||||||
},
|
async ({ params }) => {
|
||||||
},
|
const adminEntry = await adminStore.getById(params.adminId!);
|
||||||
)
|
if (!adminEntry) {
|
||||||
.get(
|
return { status: 404, body: { type: "text/plain", data: `Admin not found` } };
|
||||||
"/:adminId",
|
|
||||||
async ({ params, set }) => {
|
|
||||||
const adminEntry = await adminStore.get(["admins", Number(params.adminId)]);
|
|
||||||
if (!adminEntry.versionstamp) {
|
|
||||||
set.status = 404;
|
|
||||||
return "Admin not found";
|
|
||||||
}
|
}
|
||||||
return getAdminData(adminEntry);
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: getAdminData(adminEntry) },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
DELETE: createEndpoint(
|
||||||
{
|
{
|
||||||
params: t.Object({ adminId: t.String() }),
|
query: {
|
||||||
response: {
|
sessionId: { type: "string" },
|
||||||
200: adminDataSchema,
|
|
||||||
404: t.Literal("Admin not found"),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
async ({ params, query }) => {
|
||||||
.delete(
|
const deletedAdminEntry = await adminStore.getById(params.adminId!);
|
||||||
"/:adminId",
|
if (!deletedAdminEntry) {
|
||||||
async ({ params, query, set }) => {
|
return { status: 404, body: { type: "text/plain", data: `Admin not found` } };
|
||||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
|
||||||
const deletedAdminEntry = await adminStore.get(["admins", Number(params.adminId)]);
|
|
||||||
if (!deletedAdminEntry.versionstamp) {
|
|
||||||
set.status = 404;
|
|
||||||
return "Admin not found";
|
|
||||||
}
|
}
|
||||||
const deletedAdminUser = await getUser(deletedAdminEntry.key[1]);
|
const deletedAdminUser = await getUser(deletedAdminEntry.value.tgUserId);
|
||||||
await adminStore.delete(["admins", Number(params.adminId)]);
|
return withUser(query, async (chat) => {
|
||||||
info(
|
await deletedAdminEntry.delete();
|
||||||
`User ${sessionUser.first_name} demoted user ${deletedAdminUser.first_name} from admin`,
|
info(`User ${chat.first_name} demoted user ${deletedAdminUser.first_name} from admin`);
|
||||||
);
|
return {
|
||||||
return null;
|
status: 200,
|
||||||
|
body: { type: "application/json", data: null },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
),
|
||||||
params: t.Object({ adminId: t.String() }),
|
}),
|
||||||
query: t.Object({ sessionId: t.String() }),
|
}),
|
||||||
response: {
|
});
|
||||||
200: t.Null(),
|
|
||||||
401: t.Literal("Must be logged in"),
|
|
||||||
403: t.Literal("Must be an admin"),
|
|
||||||
404: t.Literal("Admin not found"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,14 +1,9 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { createEndpoint, createMethodFilter } from "t_rest/server";
|
||||||
import { bot } from "../bot/mod.ts";
|
import { bot } from "../bot/mod.ts";
|
||||||
|
|
||||||
export const botRoute = new Elysia()
|
export const botRoute = createMethodFilter({
|
||||||
.get(
|
GET: createEndpoint({ query: null, body: null }, async () => {
|
||||||
"",
|
|
||||||
async () => {
|
|
||||||
const username = bot.botInfo.username;
|
const username = bot.botInfo.username;
|
||||||
return { username };
|
return { status: 200, body: { type: "application/json", data: { username } } };
|
||||||
},
|
}),
|
||||||
{
|
});
|
||||||
response: t.Object({ username: t.String() }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
import { Chat } from "grammy_types";
|
|
||||||
import { Admin, adminStore } from "../app/adminStore.ts";
|
|
||||||
import { bot } from "../bot/mod.ts";
|
|
||||||
import { sessions } from "./sessionsRoute.ts";
|
|
||||||
import { TkvEntry } from "../utils/Tkv.ts";
|
|
||||||
|
|
||||||
export async function withSessionUser<O>(
|
|
||||||
{ query, set }: { query: { sessionId: string }; set: { status?: string | number } },
|
|
||||||
cb: (sessionUser: Chat.PrivateGetChat) => Promise<O>,
|
|
||||||
) {
|
|
||||||
const session = sessions.get(query.sessionId);
|
|
||||||
if (!session?.userId) {
|
|
||||||
set.status = 401;
|
|
||||||
return "Must be logged in";
|
|
||||||
}
|
|
||||||
const user = await getUser(session.userId);
|
|
||||||
return cb(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withSessionAdmin<O>(
|
|
||||||
{ query, set }: { query: { sessionId: string }; set: { status?: string | number } },
|
|
||||||
cb: (
|
|
||||||
sessionUser: Chat.PrivateGetChat,
|
|
||||||
sessionAdminEntry: TkvEntry<["admins", number], Admin>,
|
|
||||||
) => Promise<O>,
|
|
||||||
) {
|
|
||||||
const session = sessions.get(query.sessionId);
|
|
||||||
if (!session?.userId) {
|
|
||||||
set.status = 401;
|
|
||||||
return "Must be logged in";
|
|
||||||
}
|
|
||||||
const sessionUser = await getUser(session.userId);
|
|
||||||
const sessionAdminEntry = await adminStore.get(["admins", sessionUser.id]);
|
|
||||||
if (!sessionAdminEntry.versionstamp) {
|
|
||||||
set.status = 403;
|
|
||||||
return "Must be an admin";
|
|
||||||
}
|
|
||||||
return cb(sessionUser, sessionAdminEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUser(userId: number): Promise<Chat.PrivateGetChat> {
|
|
||||||
const chat = await bot.api.getChat(userId);
|
|
||||||
if (chat.type !== "private") throw new Error("Chat is not private");
|
|
||||||
return chat;
|
|
||||||
}
|
|
|
@ -1,40 +1,15 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { createEndpoint, createMethodFilter } from "t_rest/server";
|
||||||
import { generationQueue } from "../app/generationQueue.ts";
|
import { generationQueue } from "../app/generationQueue.ts";
|
||||||
|
|
||||||
export const jobsRoute = new Elysia()
|
export const jobsRoute = createMethodFilter({
|
||||||
.get(
|
GET: createEndpoint(
|
||||||
"",
|
{ query: null, body: null },
|
||||||
async () => {
|
async () => ({
|
||||||
const allJobs = await generationQueue.getAllJobs();
|
status: 200,
|
||||||
return allJobs.map((job) => ({
|
body: {
|
||||||
id: job.id.join(":"),
|
type: "application/json",
|
||||||
place: job.place,
|
data: await generationQueue.getAllJobs(),
|
||||||
state: {
|
|
||||||
from: {
|
|
||||||
language_code: job.state.from.language_code ?? null,
|
|
||||||
first_name: job.state.from.first_name,
|
|
||||||
last_name: job.state.from.last_name ?? null,
|
|
||||||
username: job.state.from.username ?? null,
|
|
||||||
},
|
},
|
||||||
progress: job.state.progress ?? null,
|
|
||||||
workerInstanceKey: job.state.workerInstanceKey ?? null,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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()),
|
});
|
||||||
}),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { route } from "reroute";
|
import { route } from "reroute";
|
||||||
import { serveSpa } from "serve_spa";
|
import { serveSpa } from "serve_spa";
|
||||||
import { api } from "./serveApi.ts";
|
import { serveApi } 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) => api.fetch(request),
|
"/api/*": (request) => serveApi(request),
|
||||||
"/*": (request) =>
|
"/*": (request) =>
|
||||||
serveSpa(request, {
|
serveSpa(request, {
|
||||||
fsRoot: fromFileUrl(new URL("../ui/", import.meta.url)),
|
fsRoot: fromFileUrl(new URL("../ui/", import.meta.url)),
|
||||||
|
|
|
@ -1,39 +1,34 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { deepMerge } from "std/collections/deep_merge.ts";
|
||||||
import { info } from "std/log/mod.ts";
|
import { info } from "std/log/mod.ts";
|
||||||
import { defaultParamsSchema, getConfig, setConfig } from "../app/config.ts";
|
import { createEndpoint, createMethodFilter } from "t_rest/server";
|
||||||
import { withSessionAdmin } from "./getUser.ts";
|
import { configSchema, getConfig, setConfig } from "../app/config.ts";
|
||||||
|
import { withAdmin } from "./withUser.ts";
|
||||||
|
|
||||||
export const paramsRoute = new Elysia()
|
export const paramsRoute = createMethodFilter({
|
||||||
.get(
|
GET: createEndpoint(
|
||||||
"",
|
{ query: null, body: null },
|
||||||
async () => {
|
async () => {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
return config.defaultParams;
|
return { status: 200, body: { type: "application/json", data: config.defaultParams } };
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
PATCH: createEndpoint(
|
||||||
{
|
{
|
||||||
response: {
|
query: { sessionId: { type: "string" } },
|
||||||
200: defaultParamsSchema,
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
schema: configSchema.properties.defaultParams,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
async ({ query, body }) => {
|
||||||
.patch(
|
return withAdmin(query, async (user) => {
|
||||||
"",
|
|
||||||
async ({ query, body, set }) => {
|
|
||||||
return withSessionAdmin({ query, set }, async (user) => {
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
info(`User ${user.first_name} updated default params: ${JSON.stringify(body)}`);
|
info(`User ${user.first_name} updated default params: ${JSON.stringify(body.data)}`);
|
||||||
const defaultParams = { ...config.defaultParams, ...body };
|
const defaultParams = deepMerge(config.defaultParams ?? {}, body.data);
|
||||||
await setConfig({ defaultParams });
|
await setConfig({ defaultParams });
|
||||||
return config.defaultParams;
|
return { status: 200, body: { type: "application/json", data: config.defaultParams } };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
),
|
||||||
query: t.Object({ sessionId: t.String() }),
|
});
|
||||||
body: defaultParamsSchema,
|
|
||||||
response: {
|
|
||||||
200: defaultParamsSchema,
|
|
||||||
401: t.Literal("Must be logged in"),
|
|
||||||
403: t.Literal("Must be an admin"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { Elysia } from "elysia";
|
import { createLoggerMiddleware, createPathFilter } from "t_rest/server";
|
||||||
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";
|
||||||
|
@ -9,24 +8,18 @@ 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 api = new Elysia()
|
export const serveApi = createLoggerMiddleware(
|
||||||
.use(
|
createPathFilter({
|
||||||
swagger({
|
"admins": adminsRoute,
|
||||||
path: "/docs",
|
"bot": botRoute,
|
||||||
swaggerOptions: { url: "docs/json" } as never,
|
"jobs": jobsRoute,
|
||||||
documentation: {
|
"sessions": sessionsRoute,
|
||||||
info: { title: "Eris API", version: "0.1" },
|
"settings/params": paramsRoute,
|
||||||
servers: [{ url: "/api" }],
|
"stats": statsRoute,
|
||||||
},
|
"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 Api = typeof api;
|
export type ApiHandler = typeof serveApi;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Elysia, NotFoundError, t } from "elysia";
|
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||||
import { ulid } from "ulid";
|
import { ulid } from "ulid";
|
||||||
|
|
||||||
export const sessions = new Map<string, Session>();
|
export const sessions = new Map<string, Session>();
|
||||||
|
@ -7,37 +7,30 @@ export interface Session {
|
||||||
userId?: number | undefined;
|
userId?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sessionsRoute = new Elysia()
|
export const sessionsRoute = createPathFilter({
|
||||||
.post(
|
"": createMethodFilter({
|
||||||
"",
|
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(
|
GET: createEndpoint(
|
||||||
"/:sessionId",
|
{ query: null, body: null },
|
||||||
async ({ params }) => {
|
async ({ params }) => {
|
||||||
const id = params.sessionId!;
|
const id = params.sessionId!;
|
||||||
const session = sessions.get(id);
|
const session = sessions.get(id);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new NotFoundError("Session not found");
|
return { status: 401, body: { type: "text/plain", data: "Session not found" } };
|
||||||
}
|
}
|
||||||
return { id, userId: session.userId ?? null };
|
return { status: 200, body: { type: "application/json", data: { id, ...session } } };
|
||||||
},
|
},
|
||||||
{
|
),
|
||||||
params: t.Object({ sessionId: t.String() }),
|
|
||||||
response: t.Object({
|
|
||||||
id: t.String(),
|
|
||||||
userId: t.Nullable(t.Number()),
|
|
||||||
}),
|
}),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import { Elysia, t } from "elysia";
|
|
||||||
import { subMinutes } from "date-fns";
|
import { subMinutes } from "date-fns";
|
||||||
import { dailyStatsSchema, getDailyStats } from "../app/dailyStatsStore.ts";
|
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||||
|
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";
|
||||||
import { getUserDailyStats, userDailyStatsSchema } from "../app/userDailyStatsStore.ts";
|
import { getUserDailyStats } from "../app/userDailyStatsStore.ts";
|
||||||
import { getUserStats, userStatsSchema } from "../app/userStatsStore.ts";
|
import { getUserStats } from "../app/userStatsStore.ts";
|
||||||
import { withSessionAdmin } from "./getUser.ts";
|
|
||||||
|
|
||||||
const STATS_INTERVAL_MIN = 3;
|
const STATS_INTERVAL_MIN = 3;
|
||||||
|
|
||||||
export const statsRoute = new Elysia()
|
export const statsRoute = createPathFilter({
|
||||||
.get(
|
"": createMethodFilter({
|
||||||
"",
|
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 });
|
||||||
|
@ -36,6 +36,10 @@ export const statsRoute = new Elysia()
|
||||||
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
|
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
imageCount: globalStats.imageCount,
|
imageCount: globalStats.imageCount,
|
||||||
stepCount: globalStats.stepCount,
|
stepCount: globalStats.stepCount,
|
||||||
pixelCount: globalStats.pixelCount,
|
pixelCount: globalStats.pixelCount,
|
||||||
|
@ -45,90 +49,77 @@ export const statsRoute = new Elysia()
|
||||||
stepsPerMinute,
|
stepsPerMinute,
|
||||||
pixelsPerMinute,
|
pixelsPerMinute,
|
||||||
pixelStepsPerMinute,
|
pixelStepsPerMinute,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{
|
),
|
||||||
response: {
|
|
||||||
200: t.Object({
|
|
||||||
imageCount: t.Number(),
|
|
||||||
stepCount: t.Number(),
|
|
||||||
pixelCount: t.Number(),
|
|
||||||
pixelStepCount: t.Number(),
|
|
||||||
userCount: t.Number(),
|
|
||||||
imagesPerMinute: t.Number(),
|
|
||||||
stepsPerMinute: t.Number(),
|
|
||||||
pixelsPerMinute: t.Number(),
|
|
||||||
pixelStepsPerMinute: t.Number(),
|
|
||||||
}),
|
}),
|
||||||
},
|
"daily/{year}/{month}/{day}": createMethodFilter({
|
||||||
},
|
GET: createEndpoint(
|
||||||
)
|
{ query: null, body: null },
|
||||||
.get(
|
|
||||||
"/daily/:year/:month/:day",
|
|
||||||
async ({ params }) => {
|
async ({ params }) => {
|
||||||
return getDailyStats(params.year, params.month, params.day);
|
const year = Number(params.year);
|
||||||
},
|
const month = Number(params.month);
|
||||||
{
|
const day = Number(params.day);
|
||||||
params: t.Object({
|
const stats = await getDailyStats(year, month, day);
|
||||||
year: t.Number(),
|
|
||||||
month: t.Number(),
|
|
||||||
day: t.Number(),
|
|
||||||
}),
|
|
||||||
response: {
|
|
||||||
200: dailyStatsSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/users/:userId",
|
|
||||||
async ({ params }) => {
|
|
||||||
const userId = params.userId;
|
|
||||||
// deno-lint-ignore no-unused-vars
|
|
||||||
const { tagCountMap, ...stats } = await getUserStats(userId);
|
|
||||||
return stats;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
params: t.Object({ userId: t.Number() }),
|
|
||||||
response: {
|
|
||||||
200: t.Omit(userStatsSchema, ["tagCountMap"]),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/users/:userId/tagcount",
|
|
||||||
async ({ params, query, set }) => {
|
|
||||||
return withSessionAdmin({ query, set }, async () => {
|
|
||||||
const stats = await getUserStats(params.userId);
|
|
||||||
return {
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
|
imageCount: stats.imageCount,
|
||||||
|
pixelCount: stats.pixelCount,
|
||||||
|
userCount: stats.userIds.length,
|
||||||
|
timestamp: stats.timestamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
"users/{userId}": createMethodFilter({
|
||||||
|
GET: createEndpoint(
|
||||||
|
{ query: null, body: null },
|
||||||
|
async ({ params }) => {
|
||||||
|
const userId = Number(params.userId);
|
||||||
|
const stats = await getUserStats(userId);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
|
imageCount: stats.imageCount,
|
||||||
|
pixelCount: stats.pixelCount,
|
||||||
tagCountMap: stats.tagCountMap,
|
tagCountMap: stats.tagCountMap,
|
||||||
timestamp: stats.timestamp,
|
timestamp: stats.timestamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
|
||||||
},
|
},
|
||||||
{
|
),
|
||||||
params: t.Object({ userId: t.Number() }),
|
|
||||||
query: t.Object({ sessionId: t.String() }),
|
|
||||||
response: {
|
|
||||||
200: t.Pick(userStatsSchema, ["tagCountMap", "timestamp"]),
|
|
||||||
401: t.Literal("Must be logged in"),
|
|
||||||
403: t.Literal("Must be an admin"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/users/:userId/daily/:year/:month/:day",
|
|
||||||
async ({ params }) => {
|
|
||||||
return getUserDailyStats(params.userId, params.year, params.month, params.day);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
params: t.Object({
|
|
||||||
userId: t.Number(),
|
|
||||||
year: t.Number(),
|
|
||||||
month: t.Number(),
|
|
||||||
day: t.Number(),
|
|
||||||
}),
|
}),
|
||||||
response: {
|
"users/{userId}/daily/{year}/{month}/{day}": createMethodFilter({
|
||||||
200: userDailyStatsSchema,
|
GET: createEndpoint(
|
||||||
|
{ query: null, body: null },
|
||||||
|
async ({ params }) => {
|
||||||
|
const userId = Number(params.userId);
|
||||||
|
const year = Number(params.year);
|
||||||
|
const month = Number(params.month);
|
||||||
|
const day = Number(params.day);
|
||||||
|
const stats = await getUserDailyStats(userId, year, month, day);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
|
imageCount: stats.imageCount,
|
||||||
|
pixelCount: stats.pixelCount,
|
||||||
|
timestamp: stats.timestamp,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
|
@ -1,55 +1,50 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||||
import { adminSchema, adminStore } from "../app/adminStore.ts";
|
|
||||||
import { bot } from "../bot/mod.ts";
|
import { bot } from "../bot/mod.ts";
|
||||||
import { getUser } from "./getUser.ts";
|
import { getUser } from "./withUser.ts";
|
||||||
|
import { adminStore } from "../app/adminStore.ts";
|
||||||
|
|
||||||
export const usersRoute = new Elysia()
|
export const usersRoute = createPathFilter({
|
||||||
.get(
|
"{userId}/photo": createMethodFilter({
|
||||||
"/:userId/photo",
|
GET: createEndpoint(
|
||||||
|
{ 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(
|
||||||
|
user.photo.small_file_id,
|
||||||
|
).then((file) => file.file_path)}`,
|
||||||
|
).then((resp) => resp.arrayBuffer())
|
||||||
|
: undefined;
|
||||||
|
if (!photoData) {
|
||||||
|
return { status: 404, body: { type: "text/plain", data: "User has no photo" } };
|
||||||
}
|
}
|
||||||
const photoFile = await bot.api.getFile(user.photo.small_file_id);
|
|
||||||
const photoData = await fetch(
|
|
||||||
`https://api.telegram.org/file/bot${bot.token}/${photoFile.file_path}`,
|
|
||||||
).then((resp) => {
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error("Failed to fetch photo");
|
|
||||||
}
|
|
||||||
return resp;
|
|
||||||
}).then((resp) => resp.arrayBuffer());
|
|
||||||
|
|
||||||
return new Response(new File([photoData], "avatar.jpg", { type: "image/jpeg" }));
|
|
||||||
},
|
|
||||||
{
|
|
||||||
params: t.Object({ userId: t.String() }),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/:userId",
|
|
||||||
async ({ params }) => {
|
|
||||||
const user = await getUser(Number(params.userId));
|
|
||||||
const adminEntry = await adminStore.get(["admins", user.id]);
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
status: 200,
|
||||||
first_name: user.first_name,
|
body: {
|
||||||
last_name: user.last_name ?? null,
|
type: "image/jpeg",
|
||||||
username: user.username ?? null,
|
data: new Blob([photoData], { type: "image/jpeg" }),
|
||||||
bio: user.bio ?? null,
|
},
|
||||||
admin: adminEntry.value ?? 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(adminSchema),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
"{userId}": createMethodFilter({
|
||||||
|
GET: createEndpoint(
|
||||||
|
{ query: null, body: null },
|
||||||
|
async ({ params }) => {
|
||||||
|
const user = await getUser(Number(params.userId!));
|
||||||
|
const [adminEntry] = await adminStore.getBy("tgUserId", { value: user.id });
|
||||||
|
const admin = adminEntry?.value;
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: { ...user, admin },
|
||||||
},
|
},
|
||||||
);
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Chat } from "grammy_types";
|
||||||
|
import { Model } from "indexed_kv";
|
||||||
|
import { Output } from "t_rest/client";
|
||||||
|
import { Admin, adminStore } from "../app/adminStore.ts";
|
||||||
|
import { bot } from "../bot/mod.ts";
|
||||||
|
import { sessions } from "./sessionsRoute.ts";
|
||||||
|
|
||||||
|
export async function withUser<O extends Output>(
|
||||||
|
query: { sessionId: string },
|
||||||
|
cb: (user: Chat.PrivateGetChat) => Promise<O>,
|
||||||
|
) {
|
||||||
|
const session = sessions.get(query.sessionId);
|
||||||
|
if (!session?.userId) {
|
||||||
|
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const;
|
||||||
|
}
|
||||||
|
const user = await getUser(session.userId);
|
||||||
|
return cb(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withAdmin<O extends Output>(
|
||||||
|
query: { sessionId: string },
|
||||||
|
cb: (user: Chat.PrivateGetChat, admin: Model<Admin>) => Promise<O>,
|
||||||
|
) {
|
||||||
|
const session = sessions.get(query.sessionId);
|
||||||
|
if (!session?.userId) {
|
||||||
|
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const;
|
||||||
|
}
|
||||||
|
const user = await getUser(session.userId);
|
||||||
|
const [admin] = await adminStore.getBy("tgUserId", { value: session.userId });
|
||||||
|
if (!admin) {
|
||||||
|
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const;
|
||||||
|
}
|
||||||
|
return cb(user, admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(userId: number): Promise<Chat.PrivateGetChat> {
|
||||||
|
const chat = await bot.api.getChat(userId);
|
||||||
|
if (chat.type !== "private") throw new Error("Chat is not private");
|
||||||
|
return chat;
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ 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 { Elysia, NotFoundError, Static, t } from "elysia";
|
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||||
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";
|
||||||
|
@ -13,27 +13,20 @@ import {
|
||||||
} 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 { withSessionAdmin } from "./getUser.ts";
|
import { withAdmin } from "./withUser.ts";
|
||||||
|
|
||||||
const workerResponseSchema = t.Intersect([
|
export type WorkerData = Omit<WorkerInstance, "sdUrl" | "sdAuth"> & {
|
||||||
t.Object({ id: t.String() }),
|
id: string;
|
||||||
t.Omit(workerInstanceSchema, ["sdUrl", "sdAuth"]),
|
isActive: boolean;
|
||||||
t.Object({
|
imagesPerMinute: number;
|
||||||
isActive: t.Boolean(),
|
stepsPerMinute: number;
|
||||||
imagesPerMinute: t.Number(),
|
pixelsPerMinute: number;
|
||||||
stepsPerMinute: t.Number(),
|
pixelStepsPerMinute: number;
|
||||||
pixelsPerMinute: t.Number(),
|
};
|
||||||
pixelStepsPerMinute: t.Number(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type WorkerResponse = Static<typeof workerResponseSchema>;
|
|
||||||
|
|
||||||
const workerRequestSchema = t.Omit(workerInstanceSchema, ["lastOnlineTime", "lastError"]);
|
|
||||||
|
|
||||||
const STATS_INTERVAL_MIN = 10;
|
const STATS_INTERVAL_MIN = 10;
|
||||||
|
|
||||||
async function getWorkerResponse(workerInstance: Model<WorkerInstance>): Promise<WorkerResponse> {
|
async function getWorkerData(workerInstance: Model<WorkerInstance>): Promise<WorkerData> {
|
||||||
const after = subMinutes(new Date(), STATS_INTERVAL_MIN);
|
const after = subMinutes(new Date(), STATS_INTERVAL_MIN);
|
||||||
|
|
||||||
const generations = await generationStore.getBy("workerInstanceKey", {
|
const generations = await generationStore.getBy("workerInstanceKey", {
|
||||||
|
@ -58,7 +51,7 @@ async function getWorkerResponse(workerInstance: Model<WorkerInstance>): Promise
|
||||||
)
|
)
|
||||||
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
|
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
|
||||||
|
|
||||||
return omitUndef({
|
return {
|
||||||
id: workerInstance.id,
|
id: workerInstance.id,
|
||||||
key: workerInstance.value.key,
|
key: workerInstance.value.key,
|
||||||
name: workerInstance.value.name,
|
name: workerInstance.value.name,
|
||||||
|
@ -69,113 +62,117 @@ async function getWorkerResponse(workerInstance: Model<WorkerInstance>): Promise
|
||||||
stepsPerMinute,
|
stepsPerMinute,
|
||||||
pixelsPerMinute,
|
pixelsPerMinute,
|
||||||
pixelStepsPerMinute,
|
pixelStepsPerMinute,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const workersRoute = new Elysia()
|
export const workersRoute = createPathFilter({
|
||||||
.get(
|
"": createMethodFilter({
|
||||||
"",
|
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(getWorkerResponse));
|
const workers = await Promise.all(workerInstances.map(getWorkerData));
|
||||||
return workers;
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: workers satisfies WorkerData[] },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
POST: createEndpoint(
|
||||||
{
|
{
|
||||||
response: t.Array(workerResponseSchema),
|
query: {
|
||||||
|
sessionId: { type: "string" },
|
||||||
},
|
},
|
||||||
)
|
body: {
|
||||||
.post(
|
type: "application/json",
|
||||||
"",
|
schema: workerInstanceSchema,
|
||||||
async ({ query, body, set }) => {
|
},
|
||||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
},
|
||||||
const workerInstance = await workerInstanceStore.create(body);
|
async ({ query, body }) => {
|
||||||
info(`User ${sessionUser.first_name} created worker ${workerInstance.value.name}`);
|
return withAdmin(query, async (user) => {
|
||||||
return await getWorkerResponse(workerInstance);
|
const workerInstance = await workerInstanceStore.create(body.data);
|
||||||
|
info(`User ${user.first_name} created worker ${workerInstance.value.name}`);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: await getWorkerData(workerInstance) },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
),
|
||||||
query: t.Object({ sessionId: t.String() }),
|
}),
|
||||||
body: workerRequestSchema,
|
|
||||||
response: {
|
"{workerId}": createPathFilter({
|
||||||
200: workerResponseSchema,
|
"": createMethodFilter({
|
||||||
401: t.Literal("Must be logged in"),
|
GET: createEndpoint(
|
||||||
403: t.Literal("Must be an admin"),
|
{ query: null, body: null },
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/:workerId",
|
|
||||||
async ({ params }) => {
|
async ({ params }) => {
|
||||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
const workerInstance = await workerInstanceStore.getById(params.workerId!);
|
||||||
if (!workerInstance) {
|
if (!workerInstance) {
|
||||||
throw new NotFoundError("Worker not found");
|
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||||
}
|
}
|
||||||
return await getWorkerResponse(workerInstance);
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: await getWorkerData(workerInstance) },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
PATCH: createEndpoint(
|
||||||
{
|
{
|
||||||
params: t.Object({ workerId: t.String() }),
|
query: {
|
||||||
response: {
|
sessionId: { type: "string" },
|
||||||
200: workerResponseSchema,
|
},
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
schema: { ...workerInstanceSchema, required: [] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
async ({ params, query, body }) => {
|
||||||
.patch(
|
const workerInstance = await workerInstanceStore.getById(params.workerId!);
|
||||||
"/:workerId",
|
|
||||||
async ({ params, query, body, set }) => {
|
|
||||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
|
||||||
if (!workerInstance) {
|
if (!workerInstance) {
|
||||||
throw new NotFoundError("Worker not found");
|
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||||
}
|
}
|
||||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
return withAdmin(query, async (user) => {
|
||||||
info(
|
info(
|
||||||
`User ${sessionUser.first_name} updated worker ${workerInstance.value.name}: ${
|
`User ${user.first_name} updated worker ${workerInstance.value.name}: ${
|
||||||
JSON.stringify(body)
|
JSON.stringify(body.data)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
await workerInstance.update(body);
|
await workerInstance.update(omitUndef(body.data));
|
||||||
return await getWorkerResponse(workerInstance);
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: await getWorkerData(workerInstance) },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
DELETE: createEndpoint(
|
||||||
{
|
{
|
||||||
params: t.Object({ workerId: t.String() }),
|
query: {
|
||||||
query: t.Object({ sessionId: t.String() }),
|
sessionId: { type: "string" },
|
||||||
body: t.Partial(workerRequestSchema),
|
|
||||||
response: {
|
|
||||||
200: workerResponseSchema,
|
|
||||||
401: t.Literal("Must be logged in"),
|
|
||||||
403: t.Literal("Must be an admin"),
|
|
||||||
},
|
},
|
||||||
|
body: null,
|
||||||
},
|
},
|
||||||
)
|
async ({ params, query }) => {
|
||||||
.delete(
|
const workerInstance = await workerInstanceStore.getById(params.workerId!);
|
||||||
"/:workerId",
|
|
||||||
async ({ params, query, set }) => {
|
|
||||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
|
||||||
if (!workerInstance) {
|
if (!workerInstance) {
|
||||||
throw new Error("Worker not found");
|
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||||
}
|
}
|
||||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
return withAdmin(query, async (user) => {
|
||||||
info(`User ${sessionUser.first_name} deleted worker ${workerInstance.value.name}`);
|
info(`User ${user.first_name} deleted worker ${workerInstance.value.name}`);
|
||||||
await workerInstance.delete();
|
await workerInstance.delete();
|
||||||
return null;
|
return { status: 200, body: { type: "application/json", data: null } };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
),
|
||||||
params: t.Object({ workerId: t.String() }),
|
}),
|
||||||
query: t.Object({ sessionId: t.String() }),
|
|
||||||
response: {
|
"loras": createMethodFilter({
|
||||||
200: t.Null(),
|
GET: createEndpoint(
|
||||||
401: t.Literal("Must be logged in"),
|
{ query: null, body: null },
|
||||||
403: t.Literal("Must be an admin"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/:workerId/loras",
|
|
||||||
async ({ params }) => {
|
async ({ params }) => {
|
||||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
const workerInstance = await workerInstanceStore.getById(params.workerId!);
|
||||||
if (!workerInstance) {
|
if (!workerInstance) {
|
||||||
throw new NotFoundError("Worker not found");
|
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||||
}
|
}
|
||||||
const sdClient = createOpenApiFetch<SdApi.paths>({
|
const sdClient = createOpenApiFetch<SdApi.paths>({
|
||||||
baseUrl: workerInstance.value.sdUrl,
|
baseUrl: workerInstance.value.sdUrl,
|
||||||
|
@ -183,32 +180,30 @@ export const workersRoute = new Elysia()
|
||||||
});
|
});
|
||||||
const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {});
|
const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {});
|
||||||
if (lorasResponse.error) {
|
if (lorasResponse.error) {
|
||||||
throw new Error(
|
return {
|
||||||
`Loras request failed: ${lorasResponse["error"]}`,
|
status: 500,
|
||||||
);
|
body: { type: "text/plain", data: `Loras request failed: ${lorasResponse["error"]}` },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const loras = (lorasResponse.data as Lora[]).map((lora) => ({
|
const loras = (lorasResponse.data as Lora[]).map((lora) => ({
|
||||||
name: lora.name,
|
name: lora.name,
|
||||||
alias: lora.alias ?? null,
|
alias: lora.alias ?? null,
|
||||||
}));
|
}));
|
||||||
return loras;
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: loras },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
{
|
|
||||||
params: t.Object({ workerId: t.String() }),
|
|
||||||
response: t.Array(
|
|
||||||
t.Object({
|
|
||||||
name: t.String(),
|
|
||||||
alias: t.Nullable(t.String()),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
},
|
}),
|
||||||
)
|
|
||||||
.get(
|
"models": createMethodFilter({
|
||||||
"/:workerId/models",
|
GET: createEndpoint(
|
||||||
|
{ query: null, body: null },
|
||||||
async ({ params }) => {
|
async ({ params }) => {
|
||||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
const workerInstance = await workerInstanceStore.getById(params.workerId!);
|
||||||
if (!workerInstance) {
|
if (!workerInstance) {
|
||||||
throw new NotFoundError("Worker not found");
|
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||||
}
|
}
|
||||||
const sdClient = createOpenApiFetch<SdApi.paths>({
|
const sdClient = createOpenApiFetch<SdApi.paths>({
|
||||||
baseUrl: workerInstance.value.sdUrl,
|
baseUrl: workerInstance.value.sdUrl,
|
||||||
|
@ -216,30 +211,29 @@ export const workersRoute = new Elysia()
|
||||||
});
|
});
|
||||||
const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {});
|
const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {});
|
||||||
if (modelsResponse.error) {
|
if (modelsResponse.error) {
|
||||||
throw new Error(
|
return {
|
||||||
`Models request failed: ${modelsResponse["error"]}`,
|
status: 500,
|
||||||
);
|
body: {
|
||||||
|
type: "text/plain",
|
||||||
|
data: `Models request failed: ${modelsResponse["error"]}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const models = modelsResponse.data.map((model) => ({
|
const models = modelsResponse.data.map((model) => ({
|
||||||
title: model.title,
|
title: model.title,
|
||||||
modelName: model.model_name,
|
modelName: model.model_name,
|
||||||
hash: model.hash ?? null,
|
hash: model.hash,
|
||||||
sha256: model.sha256 ?? null,
|
sha256: model.sha256,
|
||||||
}));
|
}));
|
||||||
return models;
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: 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()),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
},
|
}),
|
||||||
);
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export interface Lora {
|
export interface Lora {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
@ -1,11 +1,24 @@
|
||||||
import { Static, t } from "elysia";
|
import { Store } from "indexed_kv";
|
||||||
|
import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
import { db } from "./db.ts";
|
import { db } from "./db.ts";
|
||||||
import { Tkv } from "../utils/Tkv.ts";
|
|
||||||
|
|
||||||
export const adminSchema = t.Object({
|
export const adminSchema = {
|
||||||
promotedBy: t.Nullable(t.Number()),
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tgUserId: { type: "number" },
|
||||||
|
promotedBy: { type: ["string", "null"] },
|
||||||
|
},
|
||||||
|
required: ["tgUserId", "promotedBy"],
|
||||||
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
|
export type Admin = jsonType<typeof adminSchema>;
|
||||||
|
|
||||||
|
type AdminIndices = {
|
||||||
|
tgUserId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminStore = new Store<Admin, AdminIndices>(db, "adminUsers", {
|
||||||
|
indices: {
|
||||||
|
tgUserId: { getValue: (adminUser) => adminUser.tgUserId },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Admin = Static<typeof adminSchema>;
|
|
||||||
|
|
||||||
export const adminStore = new Tkv<["admins", number], Admin>(db);
|
|
||||||
|
|
|
@ -1,45 +1,49 @@
|
||||||
import { Static, t } from "elysia";
|
|
||||||
import { Tkv } from "../utils/Tkv.ts";
|
|
||||||
import { db } from "./db.ts";
|
import { db } from "./db.ts";
|
||||||
|
import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
|
|
||||||
export const defaultParamsSchema = t.Partial(t.Object({
|
export const configSchema = {
|
||||||
batch_size: t.Number(),
|
type: "object",
|
||||||
n_iter: t.Number(),
|
properties: {
|
||||||
width: t.Number(),
|
pausedReason: { type: ["string", "null"] },
|
||||||
height: t.Number(),
|
maxUserJobs: { type: "number" },
|
||||||
steps: t.Number(),
|
maxJobs: { type: "number" },
|
||||||
cfg_scale: t.Number(),
|
defaultParams: {
|
||||||
sampler_name: t.String(),
|
type: "object",
|
||||||
negative_prompt: t.String(),
|
properties: {
|
||||||
}));
|
batch_size: { type: "number" },
|
||||||
|
n_iter: { type: "number" },
|
||||||
|
width: { type: "number" },
|
||||||
|
height: { type: "number" },
|
||||||
|
steps: { type: "number" },
|
||||||
|
cfg_scale: { type: "number" },
|
||||||
|
sampler_name: { type: "string" },
|
||||||
|
negative_prompt: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["adminUsernames", "maxUserJobs", "maxJobs", "defaultParams"],
|
||||||
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
export type DefaultParams = Static<typeof defaultParamsSchema>;
|
export type Config = jsonType<typeof configSchema>;
|
||||||
|
|
||||||
export const configSchema = t.Object({
|
|
||||||
pausedReason: t.Nullable(t.String()),
|
|
||||||
maxUserJobs: t.Number(),
|
|
||||||
maxJobs: t.Number(),
|
|
||||||
defaultParams: defaultParamsSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Config = Static<typeof configSchema>;
|
|
||||||
|
|
||||||
export const configStore = new Tkv<["config"], Config>(db);
|
|
||||||
|
|
||||||
const defaultConfig: Config = {
|
|
||||||
pausedReason: null,
|
|
||||||
maxUserJobs: Infinity,
|
|
||||||
maxJobs: Infinity,
|
|
||||||
defaultParams: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getConfig(): Promise<Config> {
|
export async function getConfig(): Promise<Config> {
|
||||||
const configEntry = await configStore.get(["config"]);
|
const configEntry = await db.get<Config>(["config"]);
|
||||||
return { ...defaultConfig, ...configEntry.value };
|
const config = configEntry?.value;
|
||||||
|
return {
|
||||||
|
pausedReason: config?.pausedReason ?? null,
|
||||||
|
maxUserJobs: config?.maxUserJobs ?? Infinity,
|
||||||
|
maxJobs: config?.maxJobs ?? Infinity,
|
||||||
|
defaultParams: config?.defaultParams ?? {},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setConfig<K extends keyof Config>(newConfig: Pick<Config, K>): Promise<void> {
|
export async function setConfig(newConfig: Partial<Config>): Promise<void> {
|
||||||
const configEntry = await configStore.get(["config"]);
|
const oldConfig = await getConfig();
|
||||||
const config = { ...defaultConfig, ...configEntry.value, ...newConfig };
|
const config: Config = {
|
||||||
await configStore.atomicSet(["config"], configEntry.versionstamp, config);
|
pausedReason: newConfig.pausedReason ?? oldConfig.pausedReason,
|
||||||
|
maxUserJobs: newConfig.maxUserJobs ?? oldConfig.maxUserJobs,
|
||||||
|
maxJobs: newConfig.maxJobs ?? oldConfig.maxJobs,
|
||||||
|
defaultParams: newConfig.defaultParams ?? oldConfig.defaultParams,
|
||||||
|
};
|
||||||
|
await db.set(["config"], config);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,25 @@
|
||||||
import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns";
|
import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns";
|
||||||
import { UTCDateMini } from "date-fns/utc";
|
import { UTCDateMini } from "date-fns/utc";
|
||||||
import { Static, t } from "elysia";
|
|
||||||
import { info } from "std/log/mod.ts";
|
import { info } from "std/log/mod.ts";
|
||||||
|
import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
import { db } from "./db.ts";
|
import { db } from "./db.ts";
|
||||||
import { generationStore } from "./generationStore.ts";
|
import { generationStore } from "./generationStore.ts";
|
||||||
import { kvMemoize } from "../utils/kvMemoize.ts";
|
import { kvMemoize } from "./kvMemoize.ts";
|
||||||
|
|
||||||
export const dailyStatsSchema = t.Object({
|
export const dailyStatsSchema = {
|
||||||
userIds: t.Array(t.Number()),
|
type: "object",
|
||||||
imageCount: t.Number(),
|
properties: {
|
||||||
stepCount: t.Number(),
|
userIds: { type: "array", items: { type: "number" } },
|
||||||
pixelCount: t.Number(),
|
imageCount: { type: "number" },
|
||||||
pixelStepCount: t.Number(),
|
stepCount: { type: "number" },
|
||||||
timestamp: t.Number(),
|
pixelCount: { type: "number" },
|
||||||
});
|
pixelStepCount: { type: "number" },
|
||||||
|
timestamp: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["userIds", "imageCount", "stepCount", "pixelCount", "pixelStepCount", "timestamp"],
|
||||||
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
export type DailyStats = Static<typeof dailyStatsSchema>;
|
export type DailyStats = jsonType<typeof dailyStatsSchema>;
|
||||||
|
|
||||||
export const getDailyStats = kvMemoize(
|
export const getDailyStats = kvMemoize(
|
||||||
db,
|
db,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { JobData, Queue, Worker } from "kvmq";
|
||||||
import createOpenApiClient from "openapi_fetch";
|
import createOpenApiClient from "openapi_fetch";
|
||||||
import { delay } from "std/async/delay.ts";
|
import { delay } from "std/async/delay.ts";
|
||||||
import { decode, encode } from "std/encoding/base64.ts";
|
import { decode, encode } from "std/encoding/base64.ts";
|
||||||
import { debug, error, info, warning } from "std/log/mod.ts";
|
import { debug, error, info } from "std/log/mod.ts";
|
||||||
import { ulid } from "ulid";
|
import { ulid } from "ulid";
|
||||||
import { bot } from "../bot/mod.ts";
|
import { bot } from "../bot/mod.ts";
|
||||||
import { PngInfo } from "../bot/parsePngInfo.ts";
|
import { PngInfo } from "../bot/parsePngInfo.ts";
|
||||||
|
@ -37,6 +37,7 @@ interface GenerationJob {
|
||||||
replyMessage: Message;
|
replyMessage: Message;
|
||||||
workerInstanceKey?: string;
|
workerInstanceKey?: string;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
|
asFile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generationQueue = new Queue<GenerationJob>(db, "jobQueue");
|
export const generationQueue = new Queue<GenerationJob>(db, "jobQueue");
|
||||||
|
@ -70,8 +71,7 @@ export async function processGenerationQueue() {
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const cleanedErrorMessage = error.message.replace(/url \([^)]+\)/, "");
|
workerInstance.update({ lastError: { message: error.message, time: Date.now() } })
|
||||||
workerInstance.update({ lastError: { message: cleanedErrorMessage, time: Date.now() } })
|
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
debug(`Worker ${workerInstance.value.key} is down: ${error}`);
|
debug(`Worker ${workerInstance.value.key} is down: ${error}`);
|
||||||
});
|
});
|
||||||
|
@ -241,6 +241,8 @@ async function processGenerationJob(
|
||||||
if (progressResponse.data.progress > state.progress) {
|
if (progressResponse.data.progress > state.progress) {
|
||||||
state.progress = progressResponse.data.progress;
|
state.progress = progressResponse.data.progress;
|
||||||
await updateJob({ state: state });
|
await updateJob({ state: state });
|
||||||
|
await bot.api.sendChatAction(state.chat.id, "upload_photo", { maxAttempts: 1 })
|
||||||
|
.catch(() => undefined);
|
||||||
await bot.api.editMessageText(
|
await bot.api.editMessageText(
|
||||||
state.replyMessage.chat.id,
|
state.replyMessage.chat.id,
|
||||||
state.replyMessage.message_id,
|
state.replyMessage.message_id,
|
||||||
|
@ -283,29 +285,20 @@ async function processGenerationJob(
|
||||||
replyMessage: state.replyMessage,
|
replyMessage: state.replyMessage,
|
||||||
workerInstanceKey: workerInstance.value.key,
|
workerInstanceKey: workerInstance.value.key,
|
||||||
startDate,
|
startDate,
|
||||||
|
sendOriginal: state.task.params.file,
|
||||||
endDate: new Date(),
|
endDate: new Date(),
|
||||||
imageKeys,
|
imageKeys,
|
||||||
info,
|
info,
|
||||||
}, { retryCount: 5, retryDelayMs: 10000 });
|
}, { retryCount: 5, retryDelayMs: 10000 });
|
||||||
|
|
||||||
const uploadQueueSize = await uploadQueue.getAllJobs().then((jobs) =>
|
|
||||||
jobs.filter((job) => job.status !== "processing").length
|
|
||||||
);
|
|
||||||
|
|
||||||
// change status message to uploading images
|
// change status message to uploading images
|
||||||
await bot.api.editMessageText(
|
await bot.api.editMessageText(
|
||||||
state.replyMessage.chat.id,
|
state.replyMessage.chat.id,
|
||||||
state.replyMessage.message_id,
|
state.replyMessage.message_id,
|
||||||
uploadQueueSize > 10
|
`Uploading your images...`,
|
||||||
? `You are ${formatOrdinal(uploadQueueSize)} in upload queue.`
|
|
||||||
: `Uploading your images...`,
|
|
||||||
{ maxAttempts: 1 },
|
{ maxAttempts: 1 },
|
||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
|
|
||||||
// send upload photo action
|
|
||||||
await bot.api.sendChatAction(state.chat.id, "upload_photo", { maxAttempts: 1 })
|
|
||||||
.catch(() => undefined);
|
|
||||||
|
|
||||||
debug(`Generation finished for ${formatUserChat(state)}`);
|
debug(`Generation finished for ${formatUserChat(state)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -316,7 +309,7 @@ export async function updateGenerationQueue() {
|
||||||
while (true) {
|
while (true) {
|
||||||
const jobs = await generationQueue.getAllJobs();
|
const jobs = await generationQueue.getAllJobs();
|
||||||
|
|
||||||
const editedMessages = await Promise.all(jobs.map(async (job) => {
|
await Promise.all(jobs.map(async (job) => {
|
||||||
if (job.status === "processing") {
|
if (job.status === "processing") {
|
||||||
// if the job is processing, the worker will update its status message
|
// if the job is processing, the worker will update its status message
|
||||||
return;
|
return;
|
||||||
|
@ -325,21 +318,14 @@ export async function updateGenerationQueue() {
|
||||||
// spread the updates in time randomly
|
// spread the updates in time randomly
|
||||||
await delay(Math.random() * 3_000);
|
await delay(Math.random() * 3_000);
|
||||||
|
|
||||||
return await bot.api.editMessageText(
|
await bot.api.editMessageText(
|
||||||
job.state.replyMessage.chat.id,
|
job.state.replyMessage.chat.id,
|
||||||
job.state.replyMessage.message_id,
|
job.state.replyMessage.message_id,
|
||||||
`You are ${formatOrdinal(job.place)} in queue.`,
|
`You are ${formatOrdinal(job.place)} in queue.`,
|
||||||
{ maxAttempts: 1 },
|
{ maxAttempts: 1 },
|
||||||
).then(() => true).catch(() => false);
|
).catch(() => undefined);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const erroredMessages = editedMessages.filter((ok) => !ok);
|
|
||||||
if (erroredMessages.length > 0) {
|
|
||||||
warning(
|
|
||||||
`Updating queue status failed for ${erroredMessages.length} / ${editedMessages.length} jobs`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await delay(10_000);
|
await delay(10_000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,23 @@
|
||||||
import { addDays } from "date-fns";
|
import { addDays } from "date-fns";
|
||||||
|
import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
import { decodeTime } from "ulid";
|
import { decodeTime } from "ulid";
|
||||||
import { getDailyStats } from "./dailyStatsStore.ts";
|
import { getDailyStats } from "./dailyStatsStore.ts";
|
||||||
import { generationStore } from "./generationStore.ts";
|
import { generationStore } from "./generationStore.ts";
|
||||||
|
|
||||||
export interface GlobalStats {
|
export const globalStatsSchema = {
|
||||||
userIds: number[];
|
type: "object",
|
||||||
imageCount: number;
|
properties: {
|
||||||
stepCount: number;
|
userIds: { type: "array", items: { type: "number" } },
|
||||||
pixelCount: number;
|
imageCount: { type: "number" },
|
||||||
pixelStepCount: number;
|
stepCount: { type: "number" },
|
||||||
timestamp: number;
|
pixelCount: { type: "number" },
|
||||||
}
|
pixelStepCount: { type: "number" },
|
||||||
|
timestamp: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["userIds", "imageCount", "stepCount", "pixelCount", "pixelStepCount", "timestamp"],
|
||||||
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
|
export type GlobalStats = jsonType<typeof globalStatsSchema>;
|
||||||
|
|
||||||
export const globalStats: GlobalStats = await getGlobalStats();
|
export const globalStats: GlobalStats = await getGlobalStats();
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ interface UploadJob {
|
||||||
replyMessage: Message;
|
replyMessage: Message;
|
||||||
workerInstanceKey?: string;
|
workerInstanceKey?: string;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
|
sendOriginal: boolean;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
imageKeys: Deno.KvKey[];
|
imageKeys: Deno.KvKey[];
|
||||||
info: SdGenerationInfo;
|
info: SdGenerationInfo;
|
||||||
|
@ -71,6 +72,15 @@ export async function processUploadQueue() {
|
||||||
if (!imageType) throw new Error("Image has unknown type");
|
if (!imageType) throw new Error("Image has unknown type");
|
||||||
size += imageBuffer.byteLength;
|
size += imageBuffer.byteLength;
|
||||||
types.add(imageType.ext);
|
types.add(imageType.ext);
|
||||||
|
if (state.sendOriginal) {
|
||||||
|
return InputMediaBuilder.document(
|
||||||
|
new InputFile(imageBuffer, `image${idx}.${imageType.ext}`),
|
||||||
|
// if it can fit, add caption for first photo
|
||||||
|
idx === 0 && caption.text.length <= 1024
|
||||||
|
? { caption: caption.text, caption_entities: caption.entities }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
return InputMediaBuilder.photo(
|
return InputMediaBuilder.photo(
|
||||||
new InputFile(imageBuffer, `image${idx}.${imageType.ext}`),
|
new InputFile(imageBuffer, `image${idx}.${imageType.ext}`),
|
||||||
// if it can fit, add caption for first photo
|
// if it can fit, add caption for first photo
|
||||||
|
@ -78,6 +88,7 @@ export async function processUploadQueue() {
|
||||||
? { caption: caption.text, caption_entities: caption.entities }
|
? { caption: caption.text, caption_entities: caption.entities }
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,59 +1,24 @@
|
||||||
import { Static, t } from "elysia";
|
import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
import { kvMemoize } from "../utils/kvMemoize.ts";
|
import { kvMemoize } from "./kvMemoize.ts";
|
||||||
import { db } from "./db.ts";
|
import { db } from "./db.ts";
|
||||||
import { generationStore } from "./generationStore.ts";
|
import { generationStore } from "./generationStore.ts";
|
||||||
import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns";
|
|
||||||
import { UTCDateMini } from "date-fns/utc";
|
|
||||||
|
|
||||||
export const userDailyStatsSchema = t.Object({
|
export const userDailyStatsSchema = {
|
||||||
imageCount: t.Number(),
|
type: "object",
|
||||||
pixelCount: t.Number(),
|
properties: {
|
||||||
pixelStepCount: t.Number(),
|
imageCount: { type: "number" },
|
||||||
timestamp: t.Number(),
|
pixelCount: { type: "number" },
|
||||||
});
|
timestamp: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["imageCount", "pixelCount", "timestamp"],
|
||||||
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
export type UserDailyStats = Static<typeof userDailyStatsSchema>;
|
export type UserDailyStats = jsonType<typeof userDailyStatsSchema>;
|
||||||
|
|
||||||
export const getUserDailyStats = kvMemoize(
|
export const getUserDailyStats = kvMemoize(
|
||||||
db,
|
db,
|
||||||
["userDailyStats"],
|
["userDailyStats"],
|
||||||
async (userId: number, year: number, month: number, day: number): Promise<UserDailyStats> => {
|
async (userId: number, year: number, month: number, day: number): Promise<UserDailyStats> => {
|
||||||
let imageCount = 0;
|
throw new Error("Not implemented");
|
||||||
let pixelCount = 0;
|
|
||||||
let pixelStepCount = 0;
|
|
||||||
|
|
||||||
for await (
|
|
||||||
const generation of generationStore.listBy("fromId", {
|
|
||||||
after: new Date(Date.UTC(year, month - 1, day)),
|
|
||||||
before: new Date(Date.UTC(year, month - 1, day + 1)),
|
|
||||||
value: userId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
imageCount++;
|
|
||||||
pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0);
|
|
||||||
pixelStepCount += (generation.value.info?.width ?? 0) *
|
|
||||||
(generation.value.info?.height ?? 0) *
|
|
||||||
(generation.value.info?.steps ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
imageCount,
|
|
||||||
pixelCount,
|
|
||||||
pixelStepCount,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// expire in 1 minute if was calculated on the same day, otherwise 7-14 days.
|
|
||||||
expireIn: (result, year, month, day) => {
|
|
||||||
const requestDate = new UTCDateMini(year, month - 1, day);
|
|
||||||
const calculatedDate = new UTCDateMini(result.timestamp);
|
|
||||||
return isSameDay(requestDate, calculatedDate)
|
|
||||||
? minutesToMilliseconds(1)
|
|
||||||
: hoursToMilliseconds(24 * 7 + Math.random() * 24 * 7);
|
|
||||||
},
|
|
||||||
// should cache if the stats are non-zero
|
|
||||||
shouldCache: (result) =>
|
|
||||||
result.imageCount > 0 || result.pixelCount > 0 || result.pixelStepCount > 0,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,23 +1,38 @@
|
||||||
import { minutesToMilliseconds } from "date-fns";
|
import { minutesToMilliseconds } from "date-fns";
|
||||||
import { Store } from "indexed_kv";
|
import { Store } from "indexed_kv";
|
||||||
import { info } from "std/log/mod.ts";
|
import { info } from "std/log/mod.ts";
|
||||||
import { Static, t } from "elysia";
|
import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
import { db } from "./db.ts";
|
import { db } from "./db.ts";
|
||||||
import { generationStore } from "./generationStore.ts";
|
import { generationStore } from "./generationStore.ts";
|
||||||
import { kvMemoize } from "../utils/kvMemoize.ts";
|
import { kvMemoize } from "./kvMemoize.ts";
|
||||||
import { sortBy } from "std/collections/sort_by.ts";
|
import { sortBy } from "std/collections/sort_by.ts";
|
||||||
|
|
||||||
export const userStatsSchema = t.Object({
|
export const userStatsSchema = {
|
||||||
userId: t.Number(),
|
type: "object",
|
||||||
imageCount: t.Number(),
|
properties: {
|
||||||
stepCount: t.Number(),
|
userId: { type: "number" },
|
||||||
pixelCount: t.Number(),
|
imageCount: { type: "number" },
|
||||||
pixelStepCount: t.Number(),
|
stepCount: { type: "number" },
|
||||||
tagCountMap: t.Record(t.String(), t.Number()),
|
pixelCount: { type: "number" },
|
||||||
timestamp: t.Number(),
|
pixelStepCount: { type: "number" },
|
||||||
});
|
tagCountMap: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "number" },
|
||||||
|
},
|
||||||
|
timestamp: { type: "number" },
|
||||||
|
},
|
||||||
|
required: [
|
||||||
|
"userId",
|
||||||
|
"imageCount",
|
||||||
|
"stepCount",
|
||||||
|
"pixelCount",
|
||||||
|
"pixelStepCount",
|
||||||
|
"tagCountMap",
|
||||||
|
"timestamp",
|
||||||
|
],
|
||||||
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
export type UserStats = Static<typeof userStatsSchema>;
|
export type UserStats = jsonType<typeof userStatsSchema>;
|
||||||
|
|
||||||
type UserStatsIndices = {
|
type UserStatsIndices = {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|
|
@ -1,23 +1,37 @@
|
||||||
import { Store } from "indexed_kv";
|
import { Store } from "indexed_kv";
|
||||||
import { Static, t } from "elysia";
|
import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
import { db } from "./db.ts";
|
import { db } from "./db.ts";
|
||||||
|
|
||||||
export const workerInstanceSchema = t.Object({
|
export const workerInstanceSchema = {
|
||||||
key: t.String(),
|
type: "object",
|
||||||
name: t.Nullable(t.String()),
|
properties: {
|
||||||
sdUrl: t.String(),
|
// used for counting stats
|
||||||
sdAuth: t.Nullable(t.Object({
|
key: { type: "string" },
|
||||||
user: t.String(),
|
// used for display
|
||||||
password: t.String(),
|
name: { type: ["string", "null"] },
|
||||||
})),
|
sdUrl: { type: "string" },
|
||||||
lastOnlineTime: t.Optional(t.Number()),
|
sdAuth: {
|
||||||
lastError: t.Optional(t.Object({
|
type: ["object", "null"],
|
||||||
message: t.String(),
|
properties: {
|
||||||
time: t.Number(),
|
user: { type: "string" },
|
||||||
})),
|
password: { type: "string" },
|
||||||
});
|
},
|
||||||
|
required: ["user", "password"],
|
||||||
|
},
|
||||||
|
lastOnlineTime: { type: "number" },
|
||||||
|
lastError: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
message: { type: "string" },
|
||||||
|
time: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["message", "time"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["key", "name", "sdUrl", "sdAuth"],
|
||||||
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
export type WorkerInstance = Static<typeof workerInstanceSchema>;
|
export type WorkerInstance = jsonType<typeof workerInstanceSchema>;
|
||||||
|
|
||||||
export const workerInstanceStore = new Store<WorkerInstance>(db, "workerInstances", {
|
export const workerInstanceStore = new Store<WorkerInstance>(db, "workerInstances", {
|
||||||
indices: {},
|
indices: {},
|
||||||
|
|
|
@ -12,9 +12,9 @@ export async function broadcastCommand(ctx: CommandContext<ErisContext>) {
|
||||||
return ctx.reply("I don't know who you are.");
|
return ctx.reply("I don't know who you are.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminEntry = await adminStore.get(["admins", ctx.from.id]);
|
const [admin] = await adminStore.getBy("tgUserId", { value: ctx.from.id });
|
||||||
|
|
||||||
if (!adminEntry.versionstamp) {
|
if (!admin) {
|
||||||
return ctx.reply("Only a bot admin can use this command.");
|
return ctx.reply("Only a bot admin can use this command.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -129,7 +129,7 @@ async function img2img(
|
||||||
chat: ctx.message.chat,
|
chat: ctx.message.chat,
|
||||||
requestMessage: ctx.message,
|
requestMessage: ctx.message,
|
||||||
replyMessage: replyMessage,
|
replyMessage: replyMessage,
|
||||||
}, { priority: 0, retryCount: 3, repeatDelayMs: 10_000 });
|
}, { retryCount: 3, repeatDelayMs: 10_000 });
|
||||||
|
|
||||||
debug(`Generation (img2img) enqueued for ${formatUserChat(ctx.message)}`);
|
debug(`Generation (img2img) enqueued for ${formatUserChat(ctx.message)}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -115,7 +115,7 @@ bot.api.setMyDescription(
|
||||||
bot.api.setMyCommands([
|
bot.api.setMyCommands([
|
||||||
{ command: "txt2img", description: "Generate image from text" },
|
{ command: "txt2img", description: "Generate image from text" },
|
||||||
{ command: "img2img", description: "Generate image from image" },
|
{ command: "img2img", description: "Generate image from image" },
|
||||||
{ command: "pnginfo", description: "Try to extract prompt from raw file" },
|
{ command: "pnginfo", description: "Show generation parameters of an image" },
|
||||||
{ command: "queue", description: "Show the current queue" },
|
{ command: "queue", description: "Show the current queue" },
|
||||||
{ command: "cancel", description: "Cancel all your requests" },
|
{ command: "cancel", description: "Cancel all your requests" },
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -1,24 +1,12 @@
|
||||||
import * as ExifReader from "exifreader";
|
import { decode } from "png_chunk_text";
|
||||||
|
import extractChunks from "png_chunks_extract";
|
||||||
|
|
||||||
export function getPngInfo(pngData: ArrayBuffer): string | undefined {
|
export function getPngInfo(pngData: Uint8Array): string | undefined {
|
||||||
const info = ExifReader.load(pngData);
|
return extractChunks(pngData)
|
||||||
|
.filter((chunk) => chunk.name === "tEXt")
|
||||||
if (info.UserComment?.value && Array.isArray(info.UserComment.value)) {
|
.map((chunk) => decode(chunk.data))
|
||||||
// JPEG image
|
.find((textChunk) => textChunk.keyword === "parameters")
|
||||||
return String.fromCharCode(
|
?.text;
|
||||||
...info.UserComment.value
|
|
||||||
.filter((char): char is number => typeof char == "number")
|
|
||||||
.filter((char) => char !== 0),
|
|
||||||
).replace("UNICODE", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.parameters?.description) {
|
|
||||||
// PNG image
|
|
||||||
return info.parameters.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown image type
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PngInfo {
|
export interface PngInfo {
|
||||||
|
@ -35,6 +23,7 @@ export interface PngInfo {
|
||||||
|
|
||||||
interface PngInfoExtra extends PngInfo {
|
interface PngInfoExtra extends PngInfo {
|
||||||
upscale?: number;
|
upscale?: number;
|
||||||
|
file?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePngInfo(
|
export function parsePngInfo(
|
||||||
|
@ -125,6 +114,12 @@ export function parsePngInfo(
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "file": {
|
||||||
|
part = "params";
|
||||||
|
const file = value === "true" || value === "1" || value === "yes";
|
||||||
|
params.file = file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "model":
|
case "model":
|
||||||
case "modelhash":
|
case "modelhash":
|
||||||
case "modelname":
|
case "modelname":
|
||||||
|
|
|
@ -20,9 +20,9 @@ async function pnginfo(ctx: ErisContext, includeRepliedTo: boolean): Promise<voi
|
||||||
const document = ctx.message?.document ||
|
const document = ctx.message?.document ||
|
||||||
(includeRepliedTo ? ctx.message?.reply_to_message?.document : undefined);
|
(includeRepliedTo ? ctx.message?.reply_to_message?.document : undefined);
|
||||||
|
|
||||||
if (document?.mime_type !== "image/png" && document?.mime_type !== "image/jpeg") {
|
if (document?.mime_type !== "image/png") {
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
"Please send me a PNG or JPEG file." +
|
"Please send me a PNG file." +
|
||||||
pnginfoQuestion.messageSuffixMarkdown(),
|
pnginfoQuestion.messageSuffixMarkdown(),
|
||||||
omitUndef(
|
omitUndef(
|
||||||
{
|
{
|
||||||
|
@ -37,14 +37,7 @@ async function pnginfo(ctx: ErisContext, includeRepliedTo: boolean): Promise<voi
|
||||||
|
|
||||||
const file = await ctx.api.getFile(document.file_id);
|
const file = await ctx.api.getFile(document.file_id);
|
||||||
const buffer = await fetch(file.getUrl()).then((resp) => resp.arrayBuffer());
|
const buffer = await fetch(file.getUrl()).then((resp) => resp.arrayBuffer());
|
||||||
const info = getPngInfo(buffer);
|
const params = parsePngInfo(getPngInfo(new Uint8Array(buffer)) ?? "");
|
||||||
if (!info) {
|
|
||||||
return void await ctx.reply(
|
|
||||||
"No info found in file.",
|
|
||||||
omitUndef({ reply_to_message_id: ctx.message?.message_id }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const params = parsePngInfo(info, undefined, true);
|
|
||||||
|
|
||||||
const paramsText = fmt([
|
const paramsText = fmt([
|
||||||
`${params.prompt}\n`,
|
`${params.prompt}\n`,
|
||||||
|
|
|
@ -6,7 +6,6 @@ import { generationQueue } from "../app/generationQueue.ts";
|
||||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||||
import { ErisContext } from "./mod.ts";
|
import { ErisContext } from "./mod.ts";
|
||||||
import { getPngInfo, parsePngInfo, PngInfo } from "./parsePngInfo.ts";
|
import { getPngInfo, parsePngInfo, PngInfo } from "./parsePngInfo.ts";
|
||||||
import { adminStore } from "../app/adminStore.ts";
|
|
||||||
|
|
||||||
export const txt2imgQuestion = new StatelessQuestion<ErisContext>(
|
export const txt2imgQuestion = new StatelessQuestion<ErisContext>(
|
||||||
"txt2img",
|
"txt2img",
|
||||||
|
@ -30,7 +29,6 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
let priority = 0;
|
|
||||||
|
|
||||||
if (config.pausedReason != null) {
|
if (config.pausedReason != null) {
|
||||||
await ctx.reply(`I'm paused: ${config.pausedReason || "No reason given"}`, {
|
await ctx.reply(`I'm paused: ${config.pausedReason || "No reason given"}`, {
|
||||||
|
@ -39,11 +37,6 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminEntry = await adminStore.get(["admins", ctx.from.id]);
|
|
||||||
|
|
||||||
if (adminEntry.versionstamp) {
|
|
||||||
priority = 1;
|
|
||||||
} else {
|
|
||||||
const jobs = await generationQueue.getAllJobs();
|
const jobs = await generationQueue.getAllJobs();
|
||||||
if (jobs.length >= config.maxJobs) {
|
if (jobs.length >= config.maxJobs) {
|
||||||
await ctx.reply(`The queue is full. Try again later. (Max queue size: ${config.maxJobs})`, {
|
await ctx.reply(`The queue is full. Try again later. (Max queue size: ${config.maxJobs})`, {
|
||||||
|
@ -54,12 +47,11 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
||||||
|
|
||||||
const userJobs = jobs.filter((job) => job.state.from.id === ctx.message?.from?.id);
|
const userJobs = jobs.filter((job) => job.state.from.id === ctx.message?.from?.id);
|
||||||
if (userJobs.length >= config.maxUserJobs) {
|
if (userJobs.length >= config.maxUserJobs) {
|
||||||
await ctx.reply(`You already have ${userJobs.length} jobs in the queue. Try again later.`, {
|
await ctx.reply(`You already have ${userJobs.length} jobs in queue. Try again later.`, {
|
||||||
reply_to_message_id: ctx.message?.message_id,
|
reply_to_message_id: ctx.message?.message_id,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let params: Partial<PngInfo> = {};
|
let params: Partial<PngInfo> = {};
|
||||||
|
|
||||||
|
@ -68,26 +60,17 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
||||||
if (includeRepliedTo && repliedToMsg?.document?.mime_type === "image/png") {
|
if (includeRepliedTo && repliedToMsg?.document?.mime_type === "image/png") {
|
||||||
const file = await ctx.api.getFile(repliedToMsg.document.file_id);
|
const file = await ctx.api.getFile(repliedToMsg.document.file_id);
|
||||||
const buffer = await fetch(file.getUrl()).then((resp) => resp.arrayBuffer());
|
const buffer = await fetch(file.getUrl()).then((resp) => resp.arrayBuffer());
|
||||||
params = parsePngInfo(getPngInfo(buffer) ?? "", params);
|
params = parsePngInfo(getPngInfo(new Uint8Array(buffer)) ?? "", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
const repliedToText = repliedToMsg?.text || repliedToMsg?.caption;
|
const repliedToText = repliedToMsg?.text || repliedToMsg?.caption;
|
||||||
const isReply = includeRepliedTo && repliedToText;
|
if (includeRepliedTo && repliedToText) {
|
||||||
|
|
||||||
if (isReply) {
|
|
||||||
// TODO: remove bot command from replied to text
|
// TODO: remove bot command from replied to text
|
||||||
params = parsePngInfo(repliedToText, params);
|
params = parsePngInfo(repliedToText, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
params = parsePngInfo(match, params, true);
|
params = parsePngInfo(match, params, true);
|
||||||
|
|
||||||
if (isReply) {
|
|
||||||
const parsedInfo = parsePngInfo(repliedToText, undefined, true);
|
|
||||||
if (parsedInfo.prompt !== params.prompt) {
|
|
||||||
params.seed = parsedInfo.seed ?? -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params.prompt) {
|
if (!params.prompt) {
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
"Please tell me what you want to see." +
|
"Please tell me what you want to see." +
|
||||||
|
@ -111,7 +94,7 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
||||||
chat: ctx.message.chat,
|
chat: ctx.message.chat,
|
||||||
requestMessage: ctx.message,
|
requestMessage: ctx.message,
|
||||||
replyMessage: replyMessage,
|
replyMessage: replyMessage,
|
||||||
}, { retryCount: 3, retryDelayMs: 10_000, priority: priority });
|
}, { retryCount: 3, retryDelayMs: 10_000 });
|
||||||
|
|
||||||
debug(`Generation (txt2img) enqueued for ${formatUserChat(ctx.message)}`);
|
debug(`Generation (txt2img) enqueued for ${formatUserChat(ctx.message)}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,10 +11,6 @@
|
||||||
"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?external=elysia&dev",
|
|
||||||
"elysia/swagger": "https://esm.sh/@elysiajs/swagger@0.7.4?external=elysia&dev",
|
|
||||||
"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",
|
||||||
"grammy_autoquote": "https://lib.deno.dev/x/grammy_autoquote@1/mod.ts",
|
"grammy_autoquote": "https://lib.deno.dev/x/grammy_autoquote@1/mod.ts",
|
||||||
|
@ -27,6 +23,8 @@
|
||||||
"kvfs": "https://deno.land/x/kvfs@v0.1.0/mod.ts",
|
"kvfs": "https://deno.land/x/kvfs@v0.1.0/mod.ts",
|
||||||
"kvmq": "https://deno.land/x/kvmq@v0.3.0/mod.ts",
|
"kvmq": "https://deno.land/x/kvmq@v0.3.0/mod.ts",
|
||||||
"openapi_fetch": "https://esm.sh/openapi-fetch@0.7.6",
|
"openapi_fetch": "https://esm.sh/openapi-fetch@0.7.6",
|
||||||
|
"png_chunk_text": "https://esm.sh/png-chunk-text@1.0.0",
|
||||||
|
"png_chunks_extract": "https://esm.sh/png-chunks-extract@1.0.0",
|
||||||
"react": "https://esm.sh/react@18.2.0?dev",
|
"react": "https://esm.sh/react@18.2.0?dev",
|
||||||
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?external=react&dev",
|
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?external=react&dev",
|
||||||
"react-flip-move": "https://esm.sh/react-flip-move@3.0.5?external=react&dev",
|
"react-flip-move": "https://esm.sh/react-flip-move@3.0.5?external=react&dev",
|
||||||
|
@ -43,6 +41,8 @@
|
||||||
"std/path/": "https://deno.land/std@0.204.0/path/",
|
"std/path/": "https://deno.land/std@0.204.0/path/",
|
||||||
"swr": "https://esm.sh/swr@2.2.4?external=react&dev",
|
"swr": "https://esm.sh/swr@2.2.4?external=react&dev",
|
||||||
"swr/mutation": "https://esm.sh/swr@2.2.4/mutation?external=react&dev",
|
"swr/mutation": "https://esm.sh/swr@2.2.4/mutation?external=react&dev",
|
||||||
|
"t_rest/client": "https://esm.sh/ty-rest@0.4.1/client",
|
||||||
|
"t_rest/server": "https://esm.sh/ty-rest@0.4.1/server",
|
||||||
"twind/core": "https://esm.sh/@twind/core@1.1.3",
|
"twind/core": "https://esm.sh/@twind/core@1.1.3",
|
||||||
"twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4",
|
"twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4",
|
||||||
"ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts"
|
"ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts"
|
||||||
|
|
26
deno.lock
26
deno.lock
|
@ -299,17 +299,14 @@
|
||||||
"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/eden@0.7.4?external=elysia&dev": "28d477942e36cdeb3e57791a3acc1f52dd16378b1e639693897b21e98d7295e3",
|
|
||||||
"https://esm.sh/@elysiajs/swagger@0.7.4?dev": "78520881a756f6c3a69ccc1f306bc32e258c6e0e27f971f68231763fbafd303e",
|
|
||||||
"https://esm.sh/@elysiajs/swagger@0.7.4?external=elysia&dev": "9732dabca93af3dee2ed1781edb740f6e6bdbe88167cc6a03fa42bef3aadd315",
|
|
||||||
"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/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",
|
||||||
|
"https://esm.sh/png-chunk-text@1.0.0": "08beb86f31b5ff70240650fe095b6c4e4037e5c1d5917f9338e7633cae680356",
|
||||||
|
"https://esm.sh/png-chunks-extract@1.0.0": "da06bbd3c08199d72ab16354abe5ffd2361cb891ccbb44d757a0a0a4fbfa12b5",
|
||||||
"https://esm.sh/react-dom@18.2.0/client?external=react&dev": "2eb39c339720d727591fd55fb44ffcb6f14b06812af0a71e7a2268185b5b6e73",
|
"https://esm.sh/react-dom@18.2.0/client?external=react&dev": "2eb39c339720d727591fd55fb44ffcb6f14b06812af0a71e7a2268185b5b6e73",
|
||||||
"https://esm.sh/react-flip-move@3.0.5?external=react&dev": "4390c0777a0bec583d3e6cb5e4b33831ac937d670d894a20e4f192ce8cd21bae",
|
"https://esm.sh/react-flip-move@3.0.5?external=react&dev": "4390c0777a0bec583d3e6cb5e4b33831ac937d670d894a20e4f192ce8cd21bae",
|
||||||
"https://esm.sh/react-intl@6.4.7?external=react&dev": "60e68890e2c5ef3c02d37a89c53e056b1bbd1c8467a7aae62f0b634abc7a8a5f",
|
"https://esm.sh/react-intl@6.4.7?external=react&dev": "60e68890e2c5ef3c02d37a89c53e056b1bbd1c8467a7aae62f0b634abc7a8a5f",
|
||||||
|
@ -325,7 +322,6 @@
|
||||||
"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",
|
||||||
|
@ -335,25 +331,13 @@
|
||||||
"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/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",
|
||||||
|
@ -380,10 +364,6 @@
|
||||||
"https://esm.sh/v133/ty-rest@0.4.1/denonext/server.js": "00be1165ac96313b077629556a1587d4662f4e23bb0e815b945cc95cd3582370",
|
"https://esm.sh/v133/ty-rest@0.4.1/denonext/server.js": "00be1165ac96313b077629556a1587d4662f4e23bb0e815b945cc95cd3582370",
|
||||||
"https://esm.sh/v133/use-local-storage@3.0.0/X-ZS9yZWFjdA/denonext/use-local-storage.development.mjs": "1fdc00893fe7dac56e95e2817e05d413b674f5cb5a1c6afd8994e25c9e2a56c8",
|
"https://esm.sh/v133/use-local-storage@3.0.0/X-ZS9yZWFjdA/denonext/use-local-storage.development.mjs": "1fdc00893fe7dac56e95e2817e05d413b674f5cb5a1c6afd8994e25c9e2a56c8",
|
||||||
"https://esm.sh/v133/use-sync-external-store@1.2.0/X-ZS9yZWFjdA/denonext/shim.development.js": "5388baf48494f5abe76f8a4a30810c48e828b52f1298826aa9a3f3378e2b533f",
|
"https://esm.sh/v133/use-sync-external-store@1.2.0/X-ZS9yZWFjdA/denonext/shim.development.js": "5388baf48494f5abe76f8a4a30810c48e828b52f1298826aa9a3f3378e2b533f",
|
||||||
"https://esm.sh/v133/util-deprecate@1.0.2/denonext/util-deprecate.mjs": "f69f67cf655c38428b0934e0f7c865c055834a87cc3866b629d6b2beb21005e9",
|
"https://esm.sh/v133/util-deprecate@1.0.2/denonext/util-deprecate.mjs": "f69f67cf655c38428b0934e0f7c865c055834a87cc3866b629d6b2beb21005e9"
|
||||||
"https://esm.sh/v134/@elysiajs/eden@0.7.4/X-ZS9lbHlzaWE/denonext/eden.development.mjs": "c79c9f105d2b2062b882240272faa81cab1cbdde1113290d2d56c15ed9479986",
|
|
||||||
"https://esm.sh/v134/@elysiajs/swagger@0.7.4/X-ZS9lbHlzaWE/denonext/swagger.development.mjs": "4aa74e7b8108e9bacd7830c374ef24ee1b4100d65eef045bf6802ceaa3635554",
|
|
||||||
"https://esm.sh/v134/@sinclair/typebox@0.31.26/X-ZS9lbHlzaWE/denonext/typebox.development.mjs": "5f24db9ca594ccb61f2ae58df80a1f7690d21e117772e7b00babdb290c0747df",
|
|
||||||
"https://esm.sh/v134/lodash.clonedeep@4.5.0/X-ZS9lbHlzaWE/denonext/lodash.clonedeep.development.mjs": "f44c863cb41bcd2714103a36d97e8eb8268f56070b5cd56dcfae26c556e6622e"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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", { params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "GET", { 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", { params: { userId: String(getSession.data.userId) } }] as const
|
? ["users/{userId}", "GET", { 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", { method: "GET" }] as const,
|
["admins", "GET", {}] as const,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -32,10 +32,14 @@ export function AdminsPage(props: { sessionId: string | null }) {
|
||||||
<button
|
<button
|
||||||
className="button-filled"
|
className="button-filled"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
fetchApi("/admins/promote_self", {
|
mutate(
|
||||||
method: "POST",
|
(key) => Array.isArray(key) && key[0] === "admins",
|
||||||
|
async () =>
|
||||||
|
fetchApi("admins/promote_self", "POST", {
|
||||||
query: { sessionId: sessionId ?? "" },
|
query: { sessionId: sessionId ?? "" },
|
||||||
}).then(handleResponse).then(() => mutate(() => true));
|
}).then(handleResponse),
|
||||||
|
{ populateCache: false },
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Promote me to admin
|
Promote me to admin
|
||||||
|
@ -44,20 +48,14 @@ export function AdminsPage(props: { sessionId: string | null }) {
|
||||||
|
|
||||||
{getAdmins.data?.length
|
{getAdmins.data?.length
|
||||||
? (
|
? (
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="my-4 flex flex-col gap-2">
|
||||||
{getAdmins.data.map((admin) => (
|
{getAdmins.data.map((admin) => (
|
||||||
<AdminListItem key={admin.tgUserId} admin={admin} sessionId={sessionId} />
|
<AdminListItem key={admin.id} admin={admin} sessionId={sessionId} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
: getAdmins.data?.length === 0
|
: getAdmins.data?.length === 0
|
||||||
? (
|
? <p>No admins</p>
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
|
||||||
<p key="no-admins" className="text-center text-gray-500">No admins.</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
: getAdmins.error
|
: getAdmins.error
|
||||||
? <p className="alert">Loading admins failed</p>
|
? <p className="alert">Loading admins failed</p>
|
||||||
: <div className="spinner self-center" />}
|
: <div className="spinner self-center" />}
|
||||||
|
@ -95,13 +93,20 @@ function AddAdminDialog(props: {
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const data = new FormData(e.target as HTMLFormElement);
|
const data = new FormData(e.target as HTMLFormElement);
|
||||||
fetchApi("/admins", {
|
mutate(
|
||||||
method: "POST",
|
(key) => Array.isArray(key) && key[0] === "admins",
|
||||||
|
async () =>
|
||||||
|
fetchApi("admins", "POST", {
|
||||||
query: { sessionId: sessionId! },
|
query: { sessionId: sessionId! },
|
||||||
body: {
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
tgUserId: Number(data.get("tgUserId") as string),
|
tgUserId: Number(data.get("tgUserId") as string),
|
||||||
},
|
},
|
||||||
}).then(handleResponse).then(() => mutate(() => true));
|
},
|
||||||
|
}).then(handleResponse),
|
||||||
|
{ populateCache: false },
|
||||||
|
);
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -141,17 +146,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", { params: { userId: String(admin.tgUserId) } }] as const,
|
["users/{userId}", "GET", { 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", { params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "GET", { 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", { params: { userId: String(getSession.data.userId) } }] as const
|
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
|
||||||
: null,
|
: null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
|
@ -159,7 +164,7 @@ function AdminListItem(props: { admin: AdminData; sessionId: string | null }) {
|
||||||
return (
|
return (
|
||||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
||||||
<p className="font-bold">
|
<p className="font-bold">
|
||||||
{getAdminUser.data?.first_name ?? admin.tgUserId} {getAdminUser.data?.last_name}{" "}
|
{getAdminUser.data?.first_name ?? admin.id} {getAdminUser.data?.last_name}{" "}
|
||||||
{getAdminUser.data?.username
|
{getAdminUser.data?.username
|
||||||
? (
|
? (
|
||||||
<a href={`https://t.me/${getAdminUser.data.username}`} className="link">
|
<a href={`https://t.me/${getAdminUser.data.username}`} className="link">
|
||||||
|
@ -187,7 +192,7 @@ function AdminListItem(props: { admin: AdminData; sessionId: string | null }) {
|
||||||
)}
|
)}
|
||||||
<DeleteAdminDialog
|
<DeleteAdminDialog
|
||||||
dialogRef={deleteDialogRef}
|
dialogRef={deleteDialogRef}
|
||||||
adminId={admin.tgUserId}
|
adminId={admin.id}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
@ -196,7 +201,7 @@ function AdminListItem(props: { admin: AdminData; sessionId: string | null }) {
|
||||||
|
|
||||||
function DeleteAdminDialog(props: {
|
function DeleteAdminDialog(props: {
|
||||||
dialogRef: React.RefObject<HTMLDialogElement>;
|
dialogRef: React.RefObject<HTMLDialogElement>;
|
||||||
adminId: number;
|
adminId: string;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { dialogRef, adminId, sessionId } = props;
|
const { dialogRef, adminId, sessionId } = props;
|
||||||
|
@ -212,12 +217,15 @@ function DeleteAdminDialog(props: {
|
||||||
className="flex flex-col gap-4 p-4"
|
className="flex flex-col gap-4 p-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
mutate(
|
||||||
fetchApi("/admins/:adminId", {
|
(key) => Array.isArray(key) && key[0] === "admins",
|
||||||
method: "DELETE",
|
async () =>
|
||||||
|
fetchApi("admins/{adminId}", "DELETE", {
|
||||||
query: { sessionId: sessionId! },
|
query: { sessionId: sessionId! },
|
||||||
params: { adminId: String(adminId) },
|
params: { adminId: adminId },
|
||||||
}).then(handleResponse).then(() => mutate(() => true));
|
}).then(handleResponse),
|
||||||
|
{ populateCache: false },
|
||||||
|
);
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -16,12 +16,10 @@ 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", { method: "POST" }).then((resp) => resp).then(handleResponse).then(
|
fetchApi("sessions", "POST", {}).then(handleResponse).then((session) => {
|
||||||
(session) => {
|
|
||||||
console.log("Initialized session", session.id);
|
console.log("Initialized session", session.id);
|
||||||
setSessionId(session.id);
|
setSessionId(session.id);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React, { ReactNode } from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { cx } from "twind/core";
|
import { cx } from "twind/core";
|
||||||
import { API_URL, fetchApi, handleResponse } from "./apiClient.ts";
|
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
|
|
||||||
function NavTab(props: { to: string; children: ReactNode }) {
|
function NavTab(props: { to: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
@ -23,42 +23,30 @@ export function AppHeader(props: {
|
||||||
const { className, sessionId, onLogOut } = props;
|
const { className, sessionId, onLogOut } = props;
|
||||||
|
|
||||||
const getSession = useSWR(
|
const getSession = useSWR(
|
||||||
sessionId ? ["/sessions/:sessionId", { method: "GET", params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "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", {
|
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
|
||||||
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", { method: "GET" }] as const,
|
["bot", "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", {
|
? ["users/{userId}/photo", "GET", {
|
||||||
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)),
|
||||||
// elysia fetch can't download file
|
|
||||||
fetch(`${API_URL}/users/${getSession.data?.userId}/photo`)
|
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) throw new Error(response.statusText);
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.then((response) => response.blob())
|
|
||||||
.then((blob) => blob ? URL.createObjectURL(blob) : null),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
|
|
||||||
export function QueuePage() {
|
export function QueuePage() {
|
||||||
const getJobs = useSWR(
|
const getJobs = useSWR(
|
||||||
["/jobs", {}] as const,
|
["jobs", "GET", {}] as const,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
{ refreshInterval: 2000 },
|
{ refreshInterval: 2000 },
|
||||||
);
|
);
|
||||||
|
@ -15,43 +15,36 @@ export function QueuePage() {
|
||||||
return (
|
return (
|
||||||
<FlipMove
|
<FlipMove
|
||||||
typeName={"ul"}
|
typeName={"ul"}
|
||||||
className="flex flex-col items-stretch gap-2 p-2 bg-zinc-200 dark:bg-zinc-800 rounded-md"
|
className="flex flex-col items-stretch gap-2 p-2 bg-zinc-200 dark:bg-zinc-800 rounded-xl"
|
||||||
enterAnimation="fade"
|
enterAnimation="fade"
|
||||||
leaveAnimation="fade"
|
leaveAnimation="fade"
|
||||||
>
|
>
|
||||||
{getJobs.data && getJobs.data.length === 0
|
{getJobs.data?.map((job) => (
|
||||||
? <li key="no-jobs" className="text-center text-gray-500">Queue is empty.</li>
|
|
||||||
: (
|
|
||||||
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-xl"
|
||||||
key={job.id}
|
key={job.id.join("/")}
|
||||||
>
|
>
|
||||||
<span className="">{job.place}.</span>
|
<span className="">
|
||||||
<span>{getFlagEmoji(job.state.from.language_code ?? undefined)}</span>
|
{job.place}.
|
||||||
|
</span>
|
||||||
|
<span>{getFlagEmoji(job.state.from.language_code)}</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
|
||||||
? (
|
? (
|
||||||
<a
|
<a className="link" href={`https://t.me/${job.state.from.username}`} target="_blank">
|
||||||
className="link"
|
|
||||||
href={`https://t.me/${job.state.from.username}`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
@{job.state.from.username}
|
@{job.state.from.username}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
<span className="flex-grow self-center h-full">
|
<span className="flex-grow self-center h-full">
|
||||||
{job.state.progress != null && (
|
{job.state.progress != null &&
|
||||||
<Progress className="w-full h-full" value={job.state.progress} />
|
<Progress className="w-full h-full" value={job.state.progress} />}
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-zinc-500 dark:text-zinc-400">
|
<span className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
{job.state.workerInstanceKey}
|
{job.state.workerInstanceKey}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</FlipMove>
|
</FlipMove>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,23 +2,22 @@ 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", { params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "GET", { 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", { params: { userId: String( getSession.data.userId) } }] as const
|
? ["users/{userId}", "GET", { 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", {}] as const,
|
["settings/params", "GET", {}] 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>>({});
|
||||||
|
@ -30,10 +29,9 @@ export function SettingsPage(props: { sessionId: string | null }) {
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
getParams.mutate(() =>
|
getParams.mutate(() =>
|
||||||
fetchApi("/settings/params", {
|
fetchApi("settings/params", "PATCH", {
|
||||||
method: "PATCH",
|
|
||||||
query: { sessionId: sessionId ?? "" },
|
query: { sessionId: sessionId ?? "" },
|
||||||
body: omitUndef(newParams ?? {}),
|
body: { type: "application/json", data: newParams ?? {} },
|
||||||
}).then(handleResponse)
|
}).then(handleResponse)
|
||||||
)
|
)
|
||||||
.then(() => setNewParams({}))
|
.then(() => setNewParams({}))
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Counter } from "./Counter.tsx";
|
||||||
|
|
||||||
export function StatsPage() {
|
export function StatsPage() {
|
||||||
const getGlobalStats = useSWR(
|
const getGlobalStats = useSWR(
|
||||||
["/stats", {}] as const,
|
["stats", "GET", {}] as const,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
{ refreshInterval: 2_000 },
|
{ refreshInterval: 2_000 },
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { RefObject, useRef } from "react";
|
import React, { RefObject, useRef, useState } from "react";
|
||||||
import { FormattedRelativeTime } from "react-intl";
|
import { FormattedRelativeTime } from "react-intl";
|
||||||
import useSWR, { useSWRConfig } from "swr";
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
import { WorkerResponse } from "../api/workersRoute.ts";
|
import { WorkerData } from "../api/workersRoute.ts";
|
||||||
import { Counter } from "./Counter.tsx";
|
import { Counter } from "./Counter.tsx";
|
||||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
|
|
||||||
|
@ -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", { params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "GET", { 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", { params: { userId: String(getSession.data.userId) } }] as const
|
? ["users/{userId}", "GET", { 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", { method: "GET" }] as const,
|
["workers", "GET", {}] as const,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
{ refreshInterval: 5000 },
|
{ refreshInterval: 5000 },
|
||||||
);
|
);
|
||||||
|
@ -31,20 +31,14 @@ export function WorkersPage(props: { sessionId: string | null }) {
|
||||||
<>
|
<>
|
||||||
{getWorkers.data?.length
|
{getWorkers.data?.length
|
||||||
? (
|
? (
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="my-4 flex flex-col gap-2">
|
||||||
{getWorkers.data?.map((worker) => (
|
{getWorkers.data?.map((worker) => (
|
||||||
<WorkerListItem key={worker.id} worker={worker} sessionId={sessionId} />
|
<WorkerListItem key={worker.id} worker={worker} sessionId={sessionId} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
: getWorkers.data?.length === 0
|
: getWorkers.data?.length === 0
|
||||||
? (
|
? <p>No workers</p>
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
|
||||||
<p key="no-workers" className="text-center text-gray-500">No workers.</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
: getWorkers.error
|
: getWorkers.error
|
||||||
? <p className="alert">Loading workers failed</p>
|
? <p className="alert">Loading workers failed</p>
|
||||||
: <div className="spinner self-center" />}
|
: <div className="spinner self-center" />}
|
||||||
|
@ -88,16 +82,23 @@ function AddWorkerDialog(props: {
|
||||||
const user = data.get("user") as string;
|
const user = data.get("user") as string;
|
||||||
const password = data.get("password") as string;
|
const password = data.get("password") as string;
|
||||||
console.log(key, name, user, password);
|
console.log(key, name, user, password);
|
||||||
fetchApi("/workers", {
|
mutate(
|
||||||
method: "POST",
|
(key) => Array.isArray(key) && key[0] === "workers",
|
||||||
|
async () =>
|
||||||
|
fetchApi("workers", "POST", {
|
||||||
query: { sessionId: sessionId! },
|
query: { sessionId: sessionId! },
|
||||||
body: {
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
key,
|
key,
|
||||||
name: name || null,
|
name: name || null,
|
||||||
sdUrl,
|
sdUrl,
|
||||||
sdAuth: user && password ? { user, password } : null,
|
sdAuth: user && password ? { user, password } : null,
|
||||||
},
|
},
|
||||||
}).then(handleResponse).then(() => mutate(() => true));
|
},
|
||||||
|
}).then(handleResponse),
|
||||||
|
{ populateCache: false },
|
||||||
|
);
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -183,7 +184,7 @@ function AddWorkerDialog(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkerListItem(props: {
|
function WorkerListItem(props: {
|
||||||
worker: WorkerResponse;
|
worker: WorkerData;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { worker, sessionId } = props;
|
const { worker, sessionId } = props;
|
||||||
|
@ -191,12 +192,12 @@ function WorkerListItem(props: {
|
||||||
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
const getSession = useSWR(
|
const getSession = useSWR(
|
||||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "GET", { 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", { params: { userId: String(getSession.data.userId) } }] as const
|
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
|
||||||
: null,
|
: null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
|
@ -289,14 +290,21 @@ function EditWorkerDialog(props: {
|
||||||
const user = data.get("user") as string;
|
const user = data.get("user") as string;
|
||||||
const password = data.get("password") as string;
|
const password = data.get("password") as string;
|
||||||
console.log(user, password);
|
console.log(user, password);
|
||||||
fetchApi("/workers/:workerId", {
|
mutate(
|
||||||
method: "PATCH",
|
(key) => Array.isArray(key) && key[0] === "workers",
|
||||||
|
async () =>
|
||||||
|
fetchApi("workers/{workerId}", "PATCH", {
|
||||||
params: { workerId: workerId },
|
params: { workerId: workerId },
|
||||||
query: { sessionId: sessionId! },
|
query: { sessionId: sessionId! },
|
||||||
body: {
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
sdAuth: user && password ? { user, password } : null,
|
sdAuth: user && password ? { user, password } : null,
|
||||||
},
|
},
|
||||||
}).then(handleResponse).then(() => mutate(() => true));
|
},
|
||||||
|
}).then(handleResponse),
|
||||||
|
{ populateCache: false },
|
||||||
|
);
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -357,11 +365,15 @@ function DeleteWorkerDialog(props: {
|
||||||
className="flex flex-col gap-4 p-4"
|
className="flex flex-col gap-4 p-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fetchApi("/workers/:workerId", {
|
mutate(
|
||||||
method: "DELETE",
|
(key) => Array.isArray(key) && key[0] === "workers",
|
||||||
|
async () =>
|
||||||
|
fetchApi("workers/{workerId}", "DELETE", {
|
||||||
params: { workerId: workerId },
|
params: { workerId: workerId },
|
||||||
query: { sessionId: sessionId! },
|
query: { sessionId: sessionId! },
|
||||||
}).then(handleResponse).then(() => mutate(() => true));
|
}).then(handleResponse),
|
||||||
|
{ populateCache: false },
|
||||||
|
);
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,19 +1,15 @@
|
||||||
import { edenFetch } from "elysia/eden";
|
import { createFetcher, Output } from "t_rest/client";
|
||||||
import { Api } from "../api/serveApi.ts";
|
import { ApiHandler } from "../api/serveApi.ts";
|
||||||
|
|
||||||
export const API_URL = "/api";
|
export const fetchApi = createFetcher<ApiHandler>({
|
||||||
|
baseUrl: `${location.origin}/api/`,
|
||||||
|
});
|
||||||
|
|
||||||
export const fetchApi = edenFetch<Api>(API_URL);
|
export function handleResponse<T extends Output>(
|
||||||
|
|
||||||
export function handleResponse<
|
|
||||||
T extends
|
|
||||||
| { data: unknown; error: null }
|
|
||||||
| { data: null; error: { status: number; value: unknown } },
|
|
||||||
>(
|
|
||||||
response: T,
|
response: T,
|
||||||
): (T & { error: null })["data"] {
|
): (T & { status: 200 })["body"]["data"] {
|
||||||
if (response.error) {
|
if (response.status !== 200) {
|
||||||
throw new Error(`${response.error?.status}: ${response.error?.value}`);
|
throw new Error(String(response.body.data));
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.body.data;
|
||||||
}
|
}
|
||||||
|
|
76
utils/Tkv.ts
76
utils/Tkv.ts
|
@ -1,76 +0,0 @@
|
||||||
export type TkvEntry<K extends Deno.KvKey, T> = {
|
|
||||||
key: readonly [...K];
|
|
||||||
value: T;
|
|
||||||
versionstamp: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TkvEntryMaybe<K extends Deno.KvKey, T> = TkvEntry<K, T> | {
|
|
||||||
key: readonly [...K];
|
|
||||||
value: null;
|
|
||||||
versionstamp: null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TkvListSelector<K extends Deno.KvKey> =
|
|
||||||
| { prefix: KvKeyPrefix<K> }
|
|
||||||
| { prefix: KvKeyPrefix<K>; start: readonly [...K] }
|
|
||||||
| { prefix: KvKeyPrefix<K>; end: readonly [...K] }
|
|
||||||
| { start: readonly [...K]; end: readonly [...K] };
|
|
||||||
|
|
||||||
export type KvKeyPrefix<Key extends Deno.KvKey> = Key extends readonly [infer Prefix, ...infer Rest]
|
|
||||||
? readonly [Prefix] | readonly [Prefix, ...Rest]
|
|
||||||
: never;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed wrapper for {@link Deno.Kv}
|
|
||||||
*/
|
|
||||||
export class Tkv<K extends Deno.KvKey, T> {
|
|
||||||
constructor(readonly db: Deno.Kv) {}
|
|
||||||
|
|
||||||
get(
|
|
||||||
key: readonly [...K],
|
|
||||||
options: Parameters<Deno.Kv["get"]>[1] = {},
|
|
||||||
): Promise<TkvEntryMaybe<K, T>> {
|
|
||||||
return this.db.get<T>(key, options) as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
set(
|
|
||||||
key: readonly [...K],
|
|
||||||
value: T,
|
|
||||||
options: Parameters<Deno.Kv["set"]>[2] = {},
|
|
||||||
): ReturnType<Deno.Kv["set"]> {
|
|
||||||
return this.db.set(key, value, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
atomicSet(
|
|
||||||
key: readonly [...K],
|
|
||||||
versionstamp: Parameters<Deno.AtomicOperation["check"]>[0]["versionstamp"],
|
|
||||||
value: T,
|
|
||||||
options: Parameters<Deno.AtomicOperation["set"]>[2] = {},
|
|
||||||
): ReturnType<Deno.AtomicOperation["commit"]> {
|
|
||||||
return this.db.atomic()
|
|
||||||
.check({ key, versionstamp })
|
|
||||||
.set(key, value, options)
|
|
||||||
.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(key: readonly [...K]): ReturnType<Deno.Kv["delete"]> {
|
|
||||||
return this.db.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
atomicDelete(
|
|
||||||
key: readonly [...K],
|
|
||||||
versionstamp: Parameters<Deno.AtomicOperation["check"]>[0]["versionstamp"],
|
|
||||||
): ReturnType<Deno.AtomicOperation["commit"]> {
|
|
||||||
return this.db.atomic()
|
|
||||||
.check({ key, versionstamp })
|
|
||||||
.delete(key)
|
|
||||||
.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
list(
|
|
||||||
selector: TkvListSelector<K>,
|
|
||||||
options: Parameters<Deno.Kv["list"]>[1] = {},
|
|
||||||
): AsyncIterableIterator<TkvEntry<K, T>> {
|
|
||||||
return this.db.list<T>(selector as Deno.KvListSelector, options) as any;
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue