forked from pinks/eris
Compare commits
33 Commits
fix-pnginf
...
main
Author | SHA1 | Date |
---|---|---|
lisq | 6ccf8a04d8 | |
lisq | 02e3c22b83 | |
lisq | 03b0c2bc89 | |
pinks | 5b6a1a3471 | |
pinks | ffd3d0dc8d | |
pinks | ec4a3c893e | |
pinks | e7d292df60 | |
pinks | fad14f685e | |
nameless | 2f62c17e32 | |
nameless | 602a63f2c9 | |
nameless | 8ced57c175 | |
nameless | d22848691c | |
nameless | af7b370321 | |
nameless | 5b61576315 | |
nameless | e270a3ab1f | |
nameless | 84ad11b709 | |
nameless | ac63e39373 | |
nameless | 73810d3204 | |
nameless | f4e7b5e1a7 | |
pinks | f678324889 | |
pinks | 6d6174ab48 | |
pinks | a3bada0da2 | |
pinks | 3a13d5c140 | |
pinks | dfa94e219d | |
pinks | a35f07b036 | |
pinks | f7b29dd150 | |
pinks | 4f2371fa8b | |
pinks | f020499f4d | |
pinks | 11d8a66c18 | |
pinks | 22d9c518be | |
pinks | 4ba6f494d2 | |
pinks | 2665fa1c02 | |
pinks | 083f6bc01c |
|
@ -1,3 +1,4 @@
|
|||
.env
|
||||
app*.db*
|
||||
updateConfig.ts
|
||||
*.db
|
||||
*.db-*
|
||||
deno.lock
|
||||
|
|
33
README.md
33
README.md
|
@ -1,5 +1,11 @@
|
|||
# Eris the Bot
|
||||
|
||||
[![Website](https://img.shields.io/website?url=https%3A%2F%2Feris.lisq.eu%2F)](https://eris.lisq.eu/)
|
||||
![Unique users](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Feris.lisq.eu%2Fapi%2Fstats&query=%24.userCount&label=unique%20users)
|
||||
![Generated images](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Feris.lisq.eu%2Fapi%2Fstats&query=%24.imageCount&label=images%20generated)
|
||||
![Processed steps](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Feris.lisq.eu%2Fapi%2Fstats&query=%24.stepCount&label=steps%20processed)
|
||||
![Painted pixels](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Feris.lisq.eu%2Fapi%2Fstats&query=%24.pixelCount&label=pixels%20painted)
|
||||
|
||||
Telegram bot for generating images from text.
|
||||
|
||||
## Requirements
|
||||
|
@ -13,22 +19,27 @@ You can put these in `.env` file or pass them as environment variables.
|
|||
|
||||
- `TG_BOT_TOKEN` - Telegram bot token. Get yours from [@BotFather](https://t.me/BotFather).
|
||||
Required.
|
||||
- `TG_ADMIN_USERNAMES` - Comma separated list of usernames of users that can use admin commands.
|
||||
- `DENO_KV_PATH` - [Deno KV](https://deno.land/api?s=Deno.openKv&unstable) database file path. A
|
||||
temporary file is used by default.
|
||||
- `LOG_LEVEL` - [Log level](https://deno.land/std@0.201.0/log/mod.ts?s=LogLevels). Default: `INFO`.
|
||||
|
||||
## Running
|
||||
|
||||
- Start stable diffusion webui: `cd sd-webui`, `./webui.sh --api`
|
||||
- Start bot: `deno task start`
|
||||
|
||||
To connect your SD to the bot, open the [Eris UI](http://localhost:5999/), login as admin and add a
|
||||
worker.
|
||||
1. Start Eris: `deno task start`
|
||||
2. Visit [Eris WebUI](http://localhost:5999/) and login via Telegram.
|
||||
3. Promote yourself to admin in the Eris WebUI.
|
||||
4. Start Stable Diffusion WebUI: `./webui.sh --api` (in SD WebUI directory)
|
||||
5. Add a new worker in the Eris WebUI.
|
||||
|
||||
## Codegen
|
||||
|
||||
The Stable Diffusion API in `app/sdApi.ts` is auto-generated. To regenerate it, first start your SD
|
||||
WebUI with `--nowebui --api`, and then run:
|
||||
The Stable Diffusion API types are auto-generated. To regenerate them, first start your SD WebUI
|
||||
with `--nowebui --api`, and then run `deno task generate`
|
||||
|
||||
```sh
|
||||
deno run npm:openapi-typescript http://localhost:7861/openapi.json -o app/sdApi.ts
|
||||
```
|
||||
## Project structure
|
||||
|
||||
- `/api` - Eris API served at `http://localhost:5999/api/`.
|
||||
- `/app` - Queue handling and other core processes.
|
||||
- `/bot` - Handling bot commands and other updates from Telegram API.
|
||||
- `/ui` - Eris WebUI frontend files served at `http://localhost:5999/`.
|
||||
- `/util` - Utility functions shared by other parts.
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
import { info } from "std/log/mod.ts";
|
||||
import { Elysia, Static, t } from "elysia";
|
||||
import { Admin, adminSchema, adminStore } from "../app/adminStore.ts";
|
||||
import { getUser, withSessionAdmin, withSessionUser } from "./getUser.ts";
|
||||
import { TkvEntry } from "../utils/Tkv.ts";
|
||||
|
||||
const adminDataSchema = t.Intersect([adminSchema, t.Object({ tgUserId: t.Number() })]);
|
||||
|
||||
export type AdminData = Static<typeof adminDataSchema>;
|
||||
|
||||
function getAdminData(adminEntry: TkvEntry<["admins", number], Admin>): AdminData {
|
||||
return { tgUserId: adminEntry.key[1], ...adminEntry.value };
|
||||
}
|
||||
|
||||
export const adminsRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const adminEntries = await Array.fromAsync(adminStore.list({ prefix: ["admins"] }));
|
||||
const admins = adminEntries.map(getAdminData);
|
||||
return admins;
|
||||
},
|
||||
{
|
||||
response: t.Array(adminDataSchema),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"",
|
||||
async ({ query, body, set }) => {
|
||||
return withSessionAdmin({ query, set }, async (sessionUser, sessionAdminEntry) => {
|
||||
const newAdminUser = await getUser(body.tgUserId);
|
||||
const newAdminKey = ["admins", body.tgUserId] as const;
|
||||
const newAdminValue = { promotedBy: sessionAdminEntry.key[1] };
|
||||
const newAdminResult = await adminStore.atomicSet(newAdminKey, null, newAdminValue);
|
||||
if (!newAdminResult.ok) {
|
||||
set.status = 409;
|
||||
return "User is already an admin";
|
||||
}
|
||||
info(`User ${sessionUser.first_name} promoted user ${newAdminUser.first_name} to admin`);
|
||||
return getAdminData({ ...newAdminResult, key: newAdminKey, value: newAdminValue });
|
||||
});
|
||||
},
|
||||
{
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
body: t.Object({
|
||||
tgUserId: t.Number(),
|
||||
}),
|
||||
response: {
|
||||
200: adminDataSchema,
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
409: t.Literal("User is already an admin"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/promote_self",
|
||||
// if there are no admins, allow any user to promote themselves
|
||||
async ({ query, set }) => {
|
||||
return withSessionUser({ query, set }, async (sessionUser) => {
|
||||
const adminEntries = await Array.fromAsync(adminStore.list({ prefix: ["admins"] }));
|
||||
if (adminEntries.length !== 0) {
|
||||
set.status = 409;
|
||||
return "You are not allowed to promote yourself";
|
||||
}
|
||||
const newAdminKey = ["admins", sessionUser.id] as const;
|
||||
const newAdminValue = { promotedBy: null };
|
||||
const newAdminResult = await adminStore.set(newAdminKey, newAdminValue);
|
||||
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,
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
409: t.Literal("You are not allowed to promote yourself"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/: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);
|
||||
},
|
||||
{
|
||||
params: t.Object({ adminId: t.String() }),
|
||||
response: {
|
||||
200: adminDataSchema,
|
||||
404: t.Literal("Admin not found"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:adminId",
|
||||
async ({ params, query, set }) => {
|
||||
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]);
|
||||
await adminStore.delete(["admins", Number(params.adminId)]);
|
||||
info(
|
||||
`User ${sessionUser.first_name} demoted user ${deletedAdminUser.first_name} from admin`,
|
||||
);
|
||||
return 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"),
|
||||
},
|
||||
},
|
||||
);
|
|
@ -0,0 +1,14 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
|
||||
export const botRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const username = bot.botInfo.username;
|
||||
return { username };
|
||||
},
|
||||
{
|
||||
response: t.Object({ username: t.String() }),
|
||||
},
|
||||
);
|
|
@ -0,0 +1,45 @@
|
|||
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,15 +1,40 @@
|
|||
import { createEndpoint, createMethodFilter } from "t_rest/server";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { generationQueue } from "../app/generationQueue.ts";
|
||||
|
||||
export const jobsRoute = createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async () => ({
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: await generationQueue.getAllJobs(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
export const jobsRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const allJobs = await generationQueue.getAllJobs();
|
||||
return allJobs.map((job) => ({
|
||||
id: job.id.join(":"),
|
||||
place: job.place,
|
||||
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 { serveSpa } from "serve_spa";
|
||||
import { serveApi } from "./serveApi.ts";
|
||||
import { fromFileUrl } from "std/path/mod.ts"
|
||||
import { api } from "./serveApi.ts";
|
||||
import { fromFileUrl } from "std/path/mod.ts";
|
||||
|
||||
export async function serveUi() {
|
||||
const server = Deno.serve({ port: 5999 }, (request) =>
|
||||
route(request, {
|
||||
"/api/*": (request) => serveApi(request),
|
||||
"/api/*": (request) => api.fetch(request),
|
||||
"/*": (request) =>
|
||||
serveSpa(request, {
|
||||
fsRoot: fromFileUrl(new URL("../ui/", import.meta.url)),
|
||||
|
|
|
@ -1,45 +1,39 @@
|
|||
import { deepMerge } from "std/collections/deep_merge.ts";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { info } from "std/log/mod.ts";
|
||||
import { createEndpoint, createMethodFilter } from "t_rest/server";
|
||||
import { configSchema, getConfig, setConfig } from "../app/config.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
import { sessions } from "./sessionsRoute.ts";
|
||||
import { defaultParamsSchema, getConfig, setConfig } from "../app/config.ts";
|
||||
import { withSessionAdmin } from "./getUser.ts";
|
||||
|
||||
export const paramsRoute = createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
export const paramsRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const config = await getConfig();
|
||||
return { status: 200, body: { type: "application/json", data: config.defaultParams } };
|
||||
return config.defaultParams;
|
||||
},
|
||||
),
|
||||
|
||||
PATCH: createEndpoint(
|
||||
{
|
||||
query: { sessionId: { type: "string" } },
|
||||
body: {
|
||||
type: "application/json",
|
||||
schema: configSchema.properties.defaultParams,
|
||||
response: {
|
||||
200: defaultParamsSchema,
|
||||
},
|
||||
},
|
||||
async ({ query, body }) => {
|
||||
const session = sessions.get(query.sessionId);
|
||||
if (!session?.userId) {
|
||||
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } };
|
||||
}
|
||||
const chat = await bot.api.getChat(session.userId);
|
||||
if (chat.type !== "private") throw new Error("Chat is not private");
|
||||
if (!chat.username) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must have a username" } };
|
||||
}
|
||||
const config = await getConfig();
|
||||
if (!config?.adminUsernames?.includes(chat.username)) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } };
|
||||
}
|
||||
info(`User ${chat.username} updated default params: ${JSON.stringify(body.data)}`);
|
||||
const defaultParams = deepMerge(config.defaultParams ?? {}, body.data);
|
||||
await setConfig({ defaultParams });
|
||||
return { status: 200, body: { type: "application/json", data: config.defaultParams } };
|
||||
)
|
||||
.patch(
|
||||
"",
|
||||
async ({ query, body, set }) => {
|
||||
return withSessionAdmin({ query, set }, async (user) => {
|
||||
const config = await getConfig();
|
||||
info(`User ${user.first_name} updated default params: ${JSON.stringify(body)}`);
|
||||
const defaultParams = { ...config.defaultParams, ...body };
|
||||
await setConfig({ defaultParams });
|
||||
return 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,34 +1,32 @@
|
|||
import {
|
||||
createEndpoint,
|
||||
createLoggerMiddleware,
|
||||
createMethodFilter,
|
||||
createPathFilter,
|
||||
} from "t_rest/server";
|
||||
import { Elysia } from "elysia";
|
||||
import { swagger } from "elysia/swagger";
|
||||
import { adminsRoute } from "./adminsRoute.ts";
|
||||
import { botRoute } from "./botRoute.ts";
|
||||
import { jobsRoute } from "./jobsRoute.ts";
|
||||
import { paramsRoute } from "./paramsRoute.ts";
|
||||
import { sessionsRoute } from "./sessionsRoute.ts";
|
||||
import { statsRoute } from "./statsRoute.ts";
|
||||
import { usersRoute } from "./usersRoute.ts";
|
||||
import { workersRoute } from "./workersRoute.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
|
||||
export const serveApi = createLoggerMiddleware(
|
||||
createPathFilter({
|
||||
"jobs": jobsRoute,
|
||||
"sessions": sessionsRoute,
|
||||
"users": usersRoute,
|
||||
"settings/params": paramsRoute,
|
||||
"stats": statsRoute,
|
||||
"workers": workersRoute,
|
||||
"bot": createMethodFilter({
|
||||
// deno-lint-ignore require-await
|
||||
GET: createEndpoint({ query: null, body: null }, async () => {
|
||||
const username = bot.botInfo.username;
|
||||
return { status: 200, body: { type: "application/json", data: { username } } };
|
||||
}),
|
||||
export const api = new Elysia()
|
||||
.use(
|
||||
swagger({
|
||||
path: "/docs",
|
||||
swaggerOptions: { url: "docs/json" } as never,
|
||||
documentation: {
|
||||
info: { title: "Eris API", version: "0.1" },
|
||||
servers: [{ url: "/api" }],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ filterStatus: (status) => status >= 400 },
|
||||
);
|
||||
)
|
||||
.group("/admins", (api) => api.use(adminsRoute))
|
||||
.group("/bot", (api) => api.use(botRoute))
|
||||
.group("/jobs", (api) => api.use(jobsRoute))
|
||||
.group("/sessions", (api) => api.use(sessionsRoute))
|
||||
.group("/settings/params", (api) => api.use(paramsRoute))
|
||||
.group("/stats", (api) => api.use(statsRoute))
|
||||
.group("/users", (api) => api.use(usersRoute))
|
||||
.group("/workers", (api) => api.use(workersRoute));
|
||||
|
||||
export type ApiHandler = typeof serveApi;
|
||||
export type Api = typeof api;
|
||||
|
|
|
@ -1,37 +1,43 @@
|
|||
// deno-lint-ignore-file require-await
|
||||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||
import { Elysia, NotFoundError, t } from "elysia";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const sessions = new Map<string, Session>();
|
||||
|
||||
export interface Session {
|
||||
userId?: number;
|
||||
userId?: number | undefined;
|
||||
}
|
||||
|
||||
export const sessionsRoute = createPathFilter({
|
||||
"": createMethodFilter({
|
||||
POST: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async () => {
|
||||
const id = ulid();
|
||||
const session: Session = {};
|
||||
sessions.set(id, session);
|
||||
return { status: 200, body: { type: "application/json", data: { id, ...session } } };
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
"{sessionId}": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const id = params.sessionId;
|
||||
const session = sessions.get(id);
|
||||
if (!session) {
|
||||
return { status: 401, body: { type: "text/plain", data: "Session not found" } };
|
||||
}
|
||||
return { status: 200, body: { type: "application/json", data: { id, ...session } } };
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
||||
export const sessionsRoute = new Elysia()
|
||||
.post(
|
||||
"",
|
||||
async () => {
|
||||
const id = ulid();
|
||||
const session: Session = {};
|
||||
sessions.set(id, session);
|
||||
return { id, userId: session.userId ?? null };
|
||||
},
|
||||
{
|
||||
response: t.Object({
|
||||
id: t.String(),
|
||||
userId: t.Nullable(t.Number()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:sessionId",
|
||||
async ({ params }) => {
|
||||
const id = params.sessionId!;
|
||||
const session = sessions.get(id);
|
||||
if (!session) {
|
||||
throw new NotFoundError("Session not found");
|
||||
}
|
||||
return { id, userId: session.userId ?? null };
|
||||
},
|
||||
{
|
||||
params: t.Object({ sessionId: t.String() }),
|
||||
response: t.Object({
|
||||
id: t.String(),
|
||||
userId: t.Nullable(t.Number()),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,125 +1,134 @@
|
|||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||
import { globalStats } from "../app/globalStats.ts";
|
||||
import { getDailyStats } from "../app/dailyStatsStore.ts";
|
||||
import { getUserStats } from "../app/userStatsStore.ts";
|
||||
import { getUserDailyStats } from "../app/userDailyStatsStore.ts";
|
||||
import { generationStore } from "../app/generationStore.ts";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { subMinutes } from "date-fns";
|
||||
import { dailyStatsSchema, getDailyStats } from "../app/dailyStatsStore.ts";
|
||||
import { generationStore } from "../app/generationStore.ts";
|
||||
import { globalStats } from "../app/globalStats.ts";
|
||||
import { getUserDailyStats, userDailyStatsSchema } from "../app/userDailyStatsStore.ts";
|
||||
import { getUserStats, userStatsSchema } from "../app/userStatsStore.ts";
|
||||
import { withSessionAdmin } from "./getUser.ts";
|
||||
|
||||
const STATS_INTERVAL_MIN = 3;
|
||||
|
||||
export const statsRoute = createPathFilter({
|
||||
"": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async () => {
|
||||
const after = subMinutes(new Date(), STATS_INTERVAL_MIN);
|
||||
const generations = await generationStore.getAll({ after });
|
||||
export const statsRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const after = subMinutes(new Date(), STATS_INTERVAL_MIN);
|
||||
const generations = await generationStore.getAll({ after });
|
||||
|
||||
const imagesPerMinute = generations.length / STATS_INTERVAL_MIN;
|
||||
const imagesPerMinute = generations.length / STATS_INTERVAL_MIN;
|
||||
|
||||
const stepsPerMinute = generations
|
||||
.map((generation) => generation.value.info?.steps ?? 0)
|
||||
.reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN;
|
||||
const stepsPerMinute = generations
|
||||
.map((generation) => generation.value.info?.steps ?? 0)
|
||||
.reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
const pixelsPerMinute = generations
|
||||
.map((generation) =>
|
||||
(generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0)
|
||||
)
|
||||
.reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN;
|
||||
const pixelsPerMinute = generations
|
||||
.map((generation) =>
|
||||
(generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0)
|
||||
)
|
||||
.reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
const pixelStepsPerMinute = generations
|
||||
.map((generation) =>
|
||||
(generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) *
|
||||
(generation.value.info?.steps ?? 0)
|
||||
)
|
||||
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
|
||||
const pixelStepsPerMinute = generations
|
||||
.map((generation) =>
|
||||
(generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) *
|
||||
(generation.value.info?.steps ?? 0)
|
||||
)
|
||||
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: {
|
||||
imageCount: globalStats.imageCount,
|
||||
stepCount: globalStats.stepCount,
|
||||
pixelCount: globalStats.pixelCount,
|
||||
pixelStepCount: globalStats.pixelStepCount,
|
||||
userCount: globalStats.userIds.length,
|
||||
imagesPerMinute,
|
||||
stepsPerMinute,
|
||||
pixelsPerMinute,
|
||||
pixelStepsPerMinute,
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
imageCount: globalStats.imageCount,
|
||||
stepCount: globalStats.stepCount,
|
||||
pixelCount: globalStats.pixelCount,
|
||||
pixelStepCount: globalStats.pixelStepCount,
|
||||
userCount: globalStats.userIds.length,
|
||||
imagesPerMinute,
|
||||
stepsPerMinute,
|
||||
pixelsPerMinute,
|
||||
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 },
|
||||
async ({ params }) => {
|
||||
const year = Number(params.year);
|
||||
const month = Number(params.month);
|
||||
const day = Number(params.day);
|
||||
const stats = await getDailyStats(year, month, day);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: {
|
||||
imageCount: stats.imageCount,
|
||||
pixelCount: stats.pixelCount,
|
||||
userCount: stats.userIds.length,
|
||||
timestamp: stats.timestamp,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/daily/:year/:month/:day",
|
||||
async ({ params }) => {
|
||||
return getDailyStats(params.year, params.month, params.day);
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
year: t.Number(),
|
||||
month: t.Number(),
|
||||
day: t.Number(),
|
||||
}),
|
||||
response: {
|
||||
200: dailyStatsSchema,
|
||||
},
|
||||
),
|
||||
}),
|
||||
"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,
|
||||
timestamp: stats.timestamp,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
)
|
||||
.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"]),
|
||||
},
|
||||
),
|
||||
}),
|
||||
"users/{userId}/daily/{year}/{month}/{day}": createMethodFilter({
|
||||
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);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/users/:userId/tagcount",
|
||||
async ({ params, query, set }) => {
|
||||
return withSessionAdmin({ query, set }, async () => {
|
||||
const stats = await getUserStats(params.userId);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: {
|
||||
imageCount: stats.imageCount,
|
||||
pixelCount: stats.pixelCount,
|
||||
timestamp: stats.timestamp,
|
||||
},
|
||||
},
|
||||
tagCountMap: stats.tagCountMap,
|
||||
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: {
|
||||
200: userDailyStatsSchema,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,55 +1,55 @@
|
|||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||
import { getConfig } from "../app/config.ts";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { adminSchema, adminStore } from "../app/adminStore.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
import { getUser } from "./getUser.ts";
|
||||
|
||||
export const usersRoute = createPathFilter({
|
||||
"{userId}/photo": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const chat = await bot.api.getChat(params.userId);
|
||||
if (chat.type !== "private") {
|
||||
throw new Error("Chat is not private");
|
||||
export const usersRoute = new Elysia()
|
||||
.get(
|
||||
"/:userId/photo",
|
||||
async ({ params }) => {
|
||||
const user = await getUser(Number(params.userId));
|
||||
if (!user.photo) {
|
||||
throw new Error("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");
|
||||
}
|
||||
const photoData = chat.photo?.small_file_id
|
||||
? await fetch(
|
||||
`https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile(
|
||||
chat.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" } };
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "image/jpeg",
|
||||
data: new Blob([photoData], { type: "image/jpeg" }),
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
return resp;
|
||||
}).then((resp) => resp.arrayBuffer());
|
||||
|
||||
"{userId}": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const chat = await bot.api.getChat(params.userId);
|
||||
if (chat.type !== "private") {
|
||||
throw new Error("Chat is not private");
|
||||
}
|
||||
const config = await getConfig();
|
||||
const isAdmin = chat.username && config?.adminUsernames?.includes(chat.username);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: { ...chat, isAdmin },
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
||||
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 {
|
||||
id: user.id,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name ?? null,
|
||||
username: user.username ?? null,
|
||||
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),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
|
|
@ -2,28 +2,38 @@ import { subMinutes } from "date-fns";
|
|||
import { Model } from "indexed_kv";
|
||||
import createOpenApiFetch from "openapi_fetch";
|
||||
import { info } from "std/log/mod.ts";
|
||||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||
import { getConfig } from "../app/config.ts";
|
||||
import { Elysia, NotFoundError, Static, t } from "elysia";
|
||||
import { activeGenerationWorkers } from "../app/generationQueue.ts";
|
||||
import { generationStore } from "../app/generationStore.ts";
|
||||
import * as SdApi from "../app/sdApi.ts";
|
||||
import { WorkerInstance, workerInstanceStore } from "../app/workerInstanceStore.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
import {
|
||||
WorkerInstance,
|
||||
workerInstanceSchema,
|
||||
workerInstanceStore,
|
||||
} from "../app/workerInstanceStore.ts";
|
||||
import { getAuthHeader } from "../utils/getAuthHeader.ts";
|
||||
import { sessions } from "./sessionsRoute.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { withSessionAdmin } from "./getUser.ts";
|
||||
|
||||
export type WorkerData = Omit<WorkerInstance, "sdUrl" | "sdAuth"> & {
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
imagesPerMinute: number;
|
||||
stepsPerMinute: number;
|
||||
pixelsPerMinute: number;
|
||||
pixelStepsPerMinute: number;
|
||||
};
|
||||
const workerResponseSchema = t.Intersect([
|
||||
t.Object({ id: t.String() }),
|
||||
t.Omit(workerInstanceSchema, ["sdUrl", "sdAuth"]),
|
||||
t.Object({
|
||||
isActive: t.Boolean(),
|
||||
imagesPerMinute: t.Number(),
|
||||
stepsPerMinute: t.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;
|
||||
|
||||
async function getWorkerData(workerInstance: Model<WorkerInstance>): Promise<WorkerData> {
|
||||
async function getWorkerResponse(workerInstance: Model<WorkerInstance>): Promise<WorkerResponse> {
|
||||
const after = subMinutes(new Date(), STATS_INTERVAL_MIN);
|
||||
|
||||
const generations = await generationStore.getBy("workerInstanceKey", {
|
||||
|
@ -48,7 +58,7 @@ async function getWorkerData(workerInstance: Model<WorkerInstance>): Promise<Wor
|
|||
)
|
||||
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
return {
|
||||
return omitUndef({
|
||||
id: workerInstance.id,
|
||||
key: workerInstance.value.key,
|
||||
name: workerInstance.value.name,
|
||||
|
@ -59,258 +69,177 @@ async function getWorkerData(workerInstance: Model<WorkerInstance>): Promise<Wor
|
|||
stepsPerMinute,
|
||||
pixelsPerMinute,
|
||||
pixelStepsPerMinute,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const workersRoute = createPathFilter({
|
||||
"": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async () => {
|
||||
const workerInstances = await workerInstanceStore.getAll();
|
||||
const workers = await Promise.all(workerInstances.map(getWorkerData));
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { type: "application/json", data: workers satisfies WorkerData[] },
|
||||
};
|
||||
export const workersRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const workerInstances = await workerInstanceStore.getAll();
|
||||
const workers = await Promise.all(workerInstances.map(getWorkerResponse));
|
||||
return workers;
|
||||
},
|
||||
{
|
||||
response: t.Array(workerResponseSchema),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"",
|
||||
async ({ query, body, set }) => {
|
||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
||||
const workerInstance = await workerInstanceStore.create(body);
|
||||
info(`User ${sessionUser.first_name} created worker ${workerInstance.value.name}`);
|
||||
return await getWorkerResponse(workerInstance);
|
||||
});
|
||||
},
|
||||
{
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
body: workerRequestSchema,
|
||||
response: {
|
||||
200: workerResponseSchema,
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
},
|
||||
),
|
||||
POST: createEndpoint(
|
||||
{
|
||||
query: {
|
||||
sessionId: { type: "string" },
|
||||
},
|
||||
body: {
|
||||
type: "application/json",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
name: { type: ["string", "null"] },
|
||||
sdUrl: { type: "string" },
|
||||
sdAuth: {
|
||||
type: ["object", "null"],
|
||||
properties: {
|
||||
user: { type: "string" },
|
||||
password: { type: "string" },
|
||||
},
|
||||
required: ["user", "password"],
|
||||
},
|
||||
},
|
||||
required: ["key", "name", "sdUrl", "sdAuth"],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:workerId",
|
||||
async ({ params }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new NotFoundError("Worker not found");
|
||||
}
|
||||
return await getWorkerResponse(workerInstance);
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
response: {
|
||||
200: workerResponseSchema,
|
||||
},
|
||||
async ({ query, body }) => {
|
||||
const session = sessions.get(query.sessionId);
|
||||
if (!session?.userId) {
|
||||
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } };
|
||||
}
|
||||
const chat = await bot.api.getChat(session.userId);
|
||||
if (chat.type !== "private") throw new Error("Chat is not private");
|
||||
if (!chat.username) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must have a username" } };
|
||||
}
|
||||
const config = await getConfig();
|
||||
if (!config?.adminUsernames?.includes(chat.username)) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } };
|
||||
}
|
||||
const workerInstance = await workerInstanceStore.create({
|
||||
key: body.data.key,
|
||||
name: body.data.name,
|
||||
sdUrl: body.data.sdUrl,
|
||||
sdAuth: body.data.sdAuth,
|
||||
});
|
||||
info(`User ${chat.username} created worker ${workerInstance.id}`);
|
||||
const worker = await getWorkerData(workerInstance);
|
||||
return {
|
||||
status: 200,
|
||||
body: { type: "application/json", data: worker satisfies WorkerData },
|
||||
};
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:workerId",
|
||||
async ({ params, query, body, set }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new NotFoundError("Worker not found");
|
||||
}
|
||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
||||
info(
|
||||
`User ${sessionUser.first_name} updated worker ${workerInstance.value.name}: ${
|
||||
JSON.stringify(body)
|
||||
}`,
|
||||
);
|
||||
await workerInstance.update(body);
|
||||
return await getWorkerResponse(workerInstance);
|
||||
});
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
body: t.Partial(workerRequestSchema),
|
||||
response: {
|
||||
200: workerResponseSchema,
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
"{workerId}": createPathFilter({
|
||||
"": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||
}
|
||||
const worker: WorkerData = await getWorkerData(workerInstance);
|
||||
return {
|
||||
status: 200,
|
||||
body: { type: "application/json", data: worker satisfies WorkerData },
|
||||
};
|
||||
},
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:workerId",
|
||||
async ({ params, query, set }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new Error("Worker not found");
|
||||
}
|
||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
||||
info(`User ${sessionUser.first_name} deleted worker ${workerInstance.value.name}`);
|
||||
await workerInstance.delete();
|
||||
return null;
|
||||
});
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
response: {
|
||||
200: t.Null(),
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:workerId/loras",
|
||||
async ({ params }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new NotFoundError("Worker not found");
|
||||
}
|
||||
const sdClient = createOpenApiFetch<SdApi.paths>({
|
||||
baseUrl: workerInstance.value.sdUrl,
|
||||
headers: getAuthHeader(workerInstance.value.sdAuth),
|
||||
});
|
||||
const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {});
|
||||
if (lorasResponse.error) {
|
||||
throw new Error(
|
||||
`Loras request failed: ${lorasResponse["error"]}`,
|
||||
);
|
||||
}
|
||||
const loras = (lorasResponse.data as Lora[]).map((lora) => ({
|
||||
name: lora.name,
|
||||
alias: lora.alias ?? null,
|
||||
}));
|
||||
return loras;
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
response: t.Array(
|
||||
t.Object({
|
||||
name: t.String(),
|
||||
alias: t.Nullable(t.String()),
|
||||
}),
|
||||
),
|
||||
PATCH: createEndpoint(
|
||||
{
|
||||
query: {
|
||||
sessionId: { type: "string" },
|
||||
},
|
||||
body: {
|
||||
type: "application/json",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
name: { type: ["string", "null"] },
|
||||
sdUrl: { type: "string" },
|
||||
auth: {
|
||||
type: ["object", "null"],
|
||||
properties: {
|
||||
user: { type: "string" },
|
||||
password: { type: "string" },
|
||||
},
|
||||
required: ["user", "password"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ params, query, body }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||
}
|
||||
const session = sessions.get(query.sessionId);
|
||||
if (!session?.userId) {
|
||||
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } };
|
||||
}
|
||||
const chat = await bot.api.getChat(session.userId);
|
||||
if (chat.type !== "private") throw new Error("Chat is not private");
|
||||
if (!chat.username) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must have a username" } };
|
||||
}
|
||||
const config = await getConfig();
|
||||
if (!config?.adminUsernames?.includes(chat.username)) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } };
|
||||
}
|
||||
if (body.data.name !== undefined) {
|
||||
workerInstance.value.name = body.data.name;
|
||||
}
|
||||
if (body.data.sdUrl !== undefined) {
|
||||
workerInstance.value.sdUrl = body.data.sdUrl;
|
||||
}
|
||||
if (body.data.auth !== undefined) {
|
||||
workerInstance.value.sdAuth = body.data.auth;
|
||||
}
|
||||
info(
|
||||
`User ${chat.username} updated worker ${params.workerId}: ${JSON.stringify(body.data)}`,
|
||||
);
|
||||
await workerInstance.update();
|
||||
const worker = await getWorkerData(workerInstance);
|
||||
return {
|
||||
status: 200,
|
||||
body: { type: "application/json", data: worker satisfies WorkerData },
|
||||
};
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:workerId/models",
|
||||
async ({ params }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new NotFoundError("Worker not found");
|
||||
}
|
||||
const sdClient = createOpenApiFetch<SdApi.paths>({
|
||||
baseUrl: workerInstance.value.sdUrl,
|
||||
headers: getAuthHeader(workerInstance.value.sdAuth),
|
||||
});
|
||||
const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {});
|
||||
if (modelsResponse.error) {
|
||||
throw new Error(
|
||||
`Models request failed: ${modelsResponse["error"]}`,
|
||||
);
|
||||
}
|
||||
const models = modelsResponse.data.map((model) => ({
|
||||
title: model.title,
|
||||
modelName: model.model_name,
|
||||
hash: model.hash ?? null,
|
||||
sha256: model.sha256 ?? null,
|
||||
}));
|
||||
return models;
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
response: t.Array(
|
||||
t.Object({
|
||||
title: t.String(),
|
||||
modelName: t.String(),
|
||||
hash: t.Nullable(t.String()),
|
||||
sha256: t.Nullable(t.String()),
|
||||
}),
|
||||
),
|
||||
DELETE: createEndpoint(
|
||||
{
|
||||
query: {
|
||||
sessionId: { type: "string" },
|
||||
},
|
||||
body: null,
|
||||
},
|
||||
async ({ params, query }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||
}
|
||||
const session = sessions.get(query.sessionId);
|
||||
if (!session?.userId) {
|
||||
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } };
|
||||
}
|
||||
const chat = await bot.api.getChat(session.userId);
|
||||
if (chat.type !== "private") throw new Error("Chat is not private");
|
||||
if (!chat.username) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must have a username" } };
|
||||
}
|
||||
const config = await getConfig();
|
||||
if (!config?.adminUsernames?.includes(chat.username)) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } };
|
||||
}
|
||||
info(`User ${chat.username} deleted worker ${params.workerId}`);
|
||||
await workerInstance.delete();
|
||||
return { status: 200, body: { type: "application/json", data: null } };
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
"loras": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||
}
|
||||
const sdClient = createOpenApiFetch<SdApi.paths>({
|
||||
baseUrl: workerInstance.value.sdUrl,
|
||||
headers: getAuthHeader(workerInstance.value.sdAuth),
|
||||
});
|
||||
const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {});
|
||||
if (lorasResponse.error) {
|
||||
return {
|
||||
status: 500,
|
||||
body: { type: "text/plain", data: `Loras request failed: ${lorasResponse["error"]}` },
|
||||
};
|
||||
}
|
||||
const loras = (lorasResponse.data as Lora[]).map((lora) => ({
|
||||
name: lora.name,
|
||||
alias: lora.alias ?? null,
|
||||
}));
|
||||
return {
|
||||
status: 200,
|
||||
body: { type: "application/json", data: loras },
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
"models": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||
}
|
||||
const sdClient = createOpenApiFetch<SdApi.paths>({
|
||||
baseUrl: workerInstance.value.sdUrl,
|
||||
headers: getAuthHeader(workerInstance.value.sdAuth),
|
||||
});
|
||||
const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {});
|
||||
if (modelsResponse.error) {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
type: "text/plain",
|
||||
data: `Models request failed: ${modelsResponse["error"]}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const models = modelsResponse.data.map((model) => ({
|
||||
title: model.title,
|
||||
modelName: model.model_name,
|
||||
hash: model.hash,
|
||||
sha256: model.sha256,
|
||||
}));
|
||||
return {
|
||||
status: 200,
|
||||
body: { type: "application/json", data: models },
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export interface Lora {
|
||||
name: string;
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import { Static, t } from "elysia";
|
||||
import { db } from "./db.ts";
|
||||
import { Tkv } from "../utils/Tkv.ts";
|
||||
|
||||
export const adminSchema = t.Object({
|
||||
promotedBy: t.Nullable(t.Number()),
|
||||
});
|
||||
|
||||
export type Admin = Static<typeof adminSchema>;
|
||||
|
||||
export const adminStore = new Tkv<["admins", number], Admin>(db);
|
|
@ -1,52 +1,45 @@
|
|||
import { Static, t } from "elysia";
|
||||
import { Tkv } from "../utils/Tkv.ts";
|
||||
import { db } from "./db.ts";
|
||||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
|
||||
export const configSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
adminUsernames: { type: "array", items: { type: "string" } },
|
||||
pausedReason: { type: ["string", "null"] },
|
||||
maxUserJobs: { type: "number" },
|
||||
maxJobs: { type: "number" },
|
||||
defaultParams: {
|
||||
type: "object",
|
||||
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 const defaultParamsSchema = t.Partial(t.Object({
|
||||
batch_size: t.Number(),
|
||||
n_iter: t.Number(),
|
||||
width: t.Number(),
|
||||
height: t.Number(),
|
||||
steps: t.Number(),
|
||||
cfg_scale: t.Number(),
|
||||
sampler_name: t.String(),
|
||||
negative_prompt: t.String(),
|
||||
}));
|
||||
|
||||
export type Config = jsonType<typeof configSchema>;
|
||||
export type DefaultParams = Static<typeof defaultParamsSchema>;
|
||||
|
||||
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> {
|
||||
const configEntry = await db.get<Config>(["config"]);
|
||||
const config = configEntry?.value;
|
||||
return {
|
||||
adminUsernames: config?.adminUsernames ?? Deno.env.get("TG_ADMIN_USERNAMES")?.split(",") ?? [],
|
||||
pausedReason: config?.pausedReason ?? null,
|
||||
maxUserJobs: config?.maxUserJobs ?? Infinity,
|
||||
maxJobs: config?.maxJobs ?? Infinity,
|
||||
defaultParams: config?.defaultParams ?? {},
|
||||
};
|
||||
const configEntry = await configStore.get(["config"]);
|
||||
return { ...defaultConfig, ...configEntry.value };
|
||||
}
|
||||
|
||||
export async function setConfig(newConfig: Partial<Config>): Promise<void> {
|
||||
const oldConfig = await getConfig();
|
||||
const config: Config = {
|
||||
adminUsernames: newConfig.adminUsernames ?? oldConfig.adminUsernames,
|
||||
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);
|
||||
export async function setConfig<K extends keyof Config>(newConfig: Pick<Config, K>): Promise<void> {
|
||||
const configEntry = await configStore.get(["config"]);
|
||||
const config = { ...defaultConfig, ...configEntry.value, ...newConfig };
|
||||
await configStore.atomicSet(["config"], configEntry.versionstamp, config);
|
||||
}
|
||||
|
|
|
@ -1,25 +1,21 @@
|
|||
import { UTCDateMini } from "@date-fns/utc";
|
||||
import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns";
|
||||
import { UTCDateMini } from "date-fns/utc";
|
||||
import { Static, t } from "elysia";
|
||||
import { info } from "std/log/mod.ts";
|
||||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
import { db } from "./db.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
import { kvMemoize } from "./kvMemoize.ts";
|
||||
import { kvMemoize } from "../utils/kvMemoize.ts";
|
||||
|
||||
export const dailyStatsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
userIds: { type: "array", items: { type: "number" } },
|
||||
imageCount: { type: "number" },
|
||||
stepCount: { type: "number" },
|
||||
pixelCount: { type: "number" },
|
||||
pixelStepCount: { type: "number" },
|
||||
timestamp: { type: "number" },
|
||||
},
|
||||
required: ["userIds", "imageCount", "stepCount", "pixelCount", "pixelStepCount", "timestamp"],
|
||||
} as const satisfies JsonSchema;
|
||||
export const dailyStatsSchema = t.Object({
|
||||
userIds: t.Array(t.Number()),
|
||||
imageCount: t.Number(),
|
||||
stepCount: t.Number(),
|
||||
pixelCount: t.Number(),
|
||||
pixelStepCount: t.Number(),
|
||||
timestamp: t.Number(),
|
||||
});
|
||||
|
||||
export type DailyStats = jsonType<typeof dailyStatsSchema>;
|
||||
export type DailyStats = Static<typeof dailyStatsSchema>;
|
||||
|
||||
export const getDailyStats = kvMemoize(
|
||||
db,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { KvFs } from "kvfs";
|
||||
|
||||
export const db = await Deno.openKv("./app.db");
|
||||
export const db = await Deno.openKv(Deno.env.get("DENO_KV_PATH"));
|
||||
export const fs = new KvFs(db);
|
||||
|
|
|
@ -11,6 +11,7 @@ import { PngInfo } from "../bot/parsePngInfo.ts";
|
|||
import { formatOrdinal } from "../utils/formatOrdinal.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { getAuthHeader } from "../utils/getAuthHeader.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { SdError } from "./SdError.ts";
|
||||
import { getConfig } from "./config.ts";
|
||||
import { db, fs } from "./db.ts";
|
||||
|
@ -45,7 +46,7 @@ export const activeGenerationWorkers = new Map<string, Worker<GenerationJob>>();
|
|||
/**
|
||||
* Initializes queue workers for each SD instance when they become online.
|
||||
*/
|
||||
export async function processGenerationQueue() {
|
||||
export async function processGenerationQueue(): Promise<never> {
|
||||
while (true) {
|
||||
for await (const workerInstance of workerInstanceStore.listAll()) {
|
||||
const activeWorker = activeGenerationWorkers.get(workerInstance.id);
|
||||
|
@ -69,7 +70,8 @@ export async function processGenerationQueue() {
|
|||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
workerInstance.update({ lastError: { message: error.message, time: Date.now() } })
|
||||
const cleanedErrorMessage = error.message.replace(/url \([^)]+\)/, "");
|
||||
workerInstance.update({ lastError: { message: cleanedErrorMessage, time: Date.now() } })
|
||||
.catch(() => undefined);
|
||||
debug(`Worker ${workerInstance.value.key} is down: ${error}`);
|
||||
});
|
||||
|
@ -140,15 +142,6 @@ async function processGenerationJob(
|
|||
debug(`Generation started for ${formatUserChat(state)}`);
|
||||
await updateJob({ state: state });
|
||||
|
||||
// check if bot can post messages in this chat
|
||||
const chat = await bot.api.getChat(state.chat.id);
|
||||
if (
|
||||
(chat.type === "group" || chat.type === "supergroup") &&
|
||||
(!chat.permissions?.can_send_messages || !chat.permissions?.can_send_photos)
|
||||
) {
|
||||
throw new Error("Bot doesn't have permissions to send photos in this chat");
|
||||
}
|
||||
|
||||
// edit the existing status message
|
||||
await bot.api.editMessageText(
|
||||
state.replyMessage.chat.id,
|
||||
|
@ -161,7 +154,7 @@ async function processGenerationJob(
|
|||
|
||||
// reduce size if worker can't handle the resolution
|
||||
const size = limitSize(
|
||||
{ ...config.defaultParams, ...state.task.params },
|
||||
omitUndef({ ...config.defaultParams, ...state.task.params }),
|
||||
1024 * 1024,
|
||||
);
|
||||
function limitSize(
|
||||
|
@ -182,18 +175,18 @@ async function processGenerationJob(
|
|||
// start generating the image
|
||||
const responsePromise = state.task.type === "txt2img"
|
||||
? workerSdClient.POST("/sdapi/v1/txt2img", {
|
||||
body: {
|
||||
body: omitUndef({
|
||||
...config.defaultParams,
|
||||
...state.task.params,
|
||||
...size,
|
||||
negative_prompt: state.task.params.negative_prompt
|
||||
? state.task.params.negative_prompt
|
||||
: config.defaultParams?.negative_prompt,
|
||||
},
|
||||
}),
|
||||
})
|
||||
: state.task.type === "img2img"
|
||||
? workerSdClient.POST("/sdapi/v1/img2img", {
|
||||
body: {
|
||||
body: omitUndef({
|
||||
...config.defaultParams,
|
||||
...state.task.params,
|
||||
...size,
|
||||
|
@ -209,7 +202,7 @@ async function processGenerationJob(
|
|||
).then((resp) => resp.arrayBuffer()),
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
|
@ -239,8 +232,6 @@ async function processGenerationJob(
|
|||
if (progressResponse.data.progress > state.progress) {
|
||||
state.progress = progressResponse.data.progress;
|
||||
await updateJob({ state: state });
|
||||
await bot.api.sendChatAction(state.chat.id, "upload_photo", { maxAttempts: 1 })
|
||||
.catch(() => undefined);
|
||||
await bot.api.editMessageText(
|
||||
state.replyMessage.chat.id,
|
||||
state.replyMessage.message_id,
|
||||
|
@ -288,41 +279,49 @@ async function processGenerationJob(
|
|||
info,
|
||||
}, { retryCount: 5, retryDelayMs: 10000 });
|
||||
|
||||
const uploadQueueSize = await uploadQueue.getAllJobs().then((jobs) =>
|
||||
jobs.filter((job) => job.status !== "processing").length
|
||||
);
|
||||
|
||||
// change status message to uploading images
|
||||
await bot.api.editMessageText(
|
||||
state.replyMessage.chat.id,
|
||||
state.replyMessage.message_id,
|
||||
`Uploading your images...`,
|
||||
uploadQueueSize > 10
|
||||
? `You are ${formatOrdinal(uploadQueueSize)} in upload queue.`
|
||||
: `Uploading your images...`,
|
||||
{ maxAttempts: 1 },
|
||||
).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)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles queue updates and updates the status message.
|
||||
*/
|
||||
export async function updateGenerationQueue() {
|
||||
export async function updateGenerationQueue(): Promise<never> {
|
||||
while (true) {
|
||||
const jobs = await generationQueue.getAllJobs();
|
||||
let index = 0;
|
||||
for (const job of jobs) {
|
||||
if (job.lockUntil > new Date()) {
|
||||
// job is currently being processed, the worker will update its status message
|
||||
continue;
|
||||
|
||||
await Promise.all(jobs.map(async (job) => {
|
||||
if (job.status === "processing") {
|
||||
// if the job is processing, the worker will update its status message
|
||||
return;
|
||||
}
|
||||
if (!job.state.replyMessage) {
|
||||
// no status message, nothing to update
|
||||
continue;
|
||||
}
|
||||
index++;
|
||||
await bot.api.editMessageText(
|
||||
|
||||
return await bot.api.editMessageText(
|
||||
job.state.replyMessage.chat.id,
|
||||
job.state.replyMessage.message_id,
|
||||
`You are ${formatOrdinal(index)} in queue.`,
|
||||
`You are ${formatOrdinal(job.place)} in queue.`,
|
||||
{ maxAttempts: 1 },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
await delay(3000);
|
||||
).catch(() => {});
|
||||
}));
|
||||
|
||||
// delay between updates based on the number of jobs
|
||||
await delay(Math.min(15_000, Math.max(3_000, jobs.length * 100)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,10 +5,10 @@ import { db } from "./db.ts";
|
|||
export interface GenerationSchema {
|
||||
from: User;
|
||||
chat: Chat;
|
||||
sdInstanceId?: string; // TODO: change to workerInstanceKey
|
||||
info?: SdGenerationInfo;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
sdInstanceId?: string | undefined; // TODO: change to workerInstanceKey
|
||||
info?: SdGenerationInfo | undefined;
|
||||
startDate?: Date | undefined;
|
||||
endDate?: Date | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,23 +1,16 @@
|
|||
import { addDays } from "date-fns";
|
||||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
import { decodeTime } from "ulid";
|
||||
import { getDailyStats } from "./dailyStatsStore.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
|
||||
export const globalStatsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
userIds: { type: "array", items: { type: "number" } },
|
||||
imageCount: { type: "number" },
|
||||
stepCount: { type: "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 interface GlobalStats {
|
||||
userIds: number[];
|
||||
imageCount: number;
|
||||
stepCount: number;
|
||||
pixelCount: number;
|
||||
pixelStepCount: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const globalStats: GlobalStats = await getGlobalStats();
|
||||
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
/**
|
||||
* Memoizes the function result in KV storage.
|
||||
*/
|
||||
export function kvMemoize<A extends Deno.KvKey, R>(
|
||||
db: Deno.Kv,
|
||||
key: Deno.KvKey,
|
||||
fn: (...args: A) => Promise<R>,
|
||||
options?: {
|
||||
expireIn?: number | ((result: R, ...args: A) => number);
|
||||
shouldRecalculate?: (result: R, ...args: A) => boolean;
|
||||
shouldCache?: (result: R, ...args: A) => boolean;
|
||||
override?: {
|
||||
set: (key: Deno.KvKey, args: A, value: R, options: { expireIn?: number }) => Promise<void>;
|
||||
get: (key: Deno.KvKey, args: A) => Promise<R | undefined>;
|
||||
};
|
||||
},
|
||||
): (...args: A) => Promise<R> {
|
||||
return async (...args) => {
|
||||
const cachedResult = options?.override?.get
|
||||
? await options.override.get(key, args)
|
||||
: (await db.get<R>([...key, ...args])).value;
|
||||
|
||||
if (cachedResult != null) {
|
||||
if (!options?.shouldRecalculate?.(cachedResult, ...args)) {
|
||||
return cachedResult;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fn(...args);
|
||||
|
||||
const expireIn = typeof options?.expireIn === "function"
|
||||
? options.expireIn(result, ...args)
|
||||
: options?.expireIn;
|
||||
|
||||
if (options?.shouldCache?.(result, ...args) ?? (result != null)) {
|
||||
if (options?.override?.set) {
|
||||
await options.override.set(key, args, result, { expireIn });
|
||||
} else {
|
||||
await db.set([...key, ...args], result, { expireIn });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
|
@ -92,7 +92,7 @@ export async function processUploadQueue() {
|
|||
// send caption in separate message if it couldn't fit
|
||||
if (caption.text.length > 1024 && caption.text.length <= 4096) {
|
||||
await bot.api.sendMessage(state.chat.id, caption.text, {
|
||||
reply_to_message_id: resultMessages[0].message_id,
|
||||
reply_to_message_id: resultMessages[0]!.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
entities: caption.entities,
|
||||
maxWait: 60,
|
||||
|
@ -132,7 +132,7 @@ export async function processUploadQueue() {
|
|||
// delete the status message
|
||||
await bot.api.deleteMessage(state.replyMessage.chat.id, state.replyMessage.message_id)
|
||||
.catch(() => undefined);
|
||||
}, { concurrency: 3 });
|
||||
}, { concurrency: 10 });
|
||||
|
||||
uploadWorker.addEventListener("error", (e) => {
|
||||
error(`Upload failed for ${formatUserChat(e.detail.job.state)}: ${e.detail.error}`);
|
||||
|
|
|
@ -1,24 +1,59 @@
|
|||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
import { kvMemoize } from "./kvMemoize.ts";
|
||||
import { Static, t } from "elysia";
|
||||
import { kvMemoize } from "../utils/kvMemoize.ts";
|
||||
import { db } from "./db.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns";
|
||||
import { UTCDateMini } from "date-fns/utc";
|
||||
|
||||
export const userDailyStatsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
imageCount: { type: "number" },
|
||||
pixelCount: { type: "number" },
|
||||
timestamp: { type: "number" },
|
||||
},
|
||||
required: ["imageCount", "pixelCount", "timestamp"],
|
||||
} as const satisfies JsonSchema;
|
||||
export const userDailyStatsSchema = t.Object({
|
||||
imageCount: t.Number(),
|
||||
pixelCount: t.Number(),
|
||||
pixelStepCount: t.Number(),
|
||||
timestamp: t.Number(),
|
||||
});
|
||||
|
||||
export type UserDailyStats = jsonType<typeof userDailyStatsSchema>;
|
||||
export type UserDailyStats = Static<typeof userDailyStatsSchema>;
|
||||
|
||||
export const getUserDailyStats = kvMemoize(
|
||||
db,
|
||||
["userDailyStats"],
|
||||
async (userId: number, year: number, month: number, day: number): Promise<UserDailyStats> => {
|
||||
throw new Error("Not implemented");
|
||||
let imageCount = 0;
|
||||
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,37 +1,23 @@
|
|||
import { minutesToMilliseconds } from "date-fns";
|
||||
import { Store } from "indexed_kv";
|
||||
import { info } from "std/log/mod.ts";
|
||||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
import { Static, t } from "elysia";
|
||||
import { db } from "./db.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
import { kvMemoize } from "./kvMemoize.ts";
|
||||
import { kvMemoize } from "../utils/kvMemoize.ts";
|
||||
import { sortBy } from "std/collections/sort_by.ts";
|
||||
|
||||
export const userStatsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
userId: { type: "number" },
|
||||
imageCount: { type: "number" },
|
||||
stepCount: { type: "number" },
|
||||
pixelCount: { type: "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 const userStatsSchema = t.Object({
|
||||
userId: t.Number(),
|
||||
imageCount: t.Number(),
|
||||
stepCount: t.Number(),
|
||||
pixelCount: t.Number(),
|
||||
pixelStepCount: t.Number(),
|
||||
tagCountMap: t.Record(t.String(), t.Number()),
|
||||
timestamp: t.Number(),
|
||||
});
|
||||
|
||||
export type UserStats = jsonType<typeof userStatsSchema>;
|
||||
export type UserStats = Static<typeof userStatsSchema>;
|
||||
|
||||
type UserStatsIndices = {
|
||||
userId: number;
|
||||
|
@ -59,7 +45,7 @@ export const getUserStats = kvMemoize(
|
|||
let stepCount = 0;
|
||||
let pixelCount = 0;
|
||||
let pixelStepCount = 0;
|
||||
const tagCountMap: Record<string, number> = {};
|
||||
const tagCountMap = new Map<string, number>();
|
||||
|
||||
info(`Calculating user stats for ${userId}`);
|
||||
|
||||
|
@ -77,7 +63,7 @@ export const getUserStats = kvMemoize(
|
|||
// split on punctuation and newlines
|
||||
.split(/[,;.]\s+|\n/)
|
||||
// remove `:weight` syntax
|
||||
.map((tag) => tag.replace(/:[\d\.]+/g, " "))
|
||||
.map((tag) => tag.replace(/:[\d\.]+/g, ""))
|
||||
// remove `(tag)` and `[tag]` syntax
|
||||
.map((tag) => tag.replace(/[()[\]]/g, " "))
|
||||
// collapse multiple whitespace to one
|
||||
|
@ -92,17 +78,25 @@ export const getUserStats = kvMemoize(
|
|||
[];
|
||||
|
||||
for (const tag of tags) {
|
||||
tagCountMap[tag] = (tagCountMap[tag] ?? 0) + 1;
|
||||
const count = tagCountMap.get(tag) ?? 0;
|
||||
tagCountMap.set(tag, count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const tagCountObj = Object.fromEntries(
|
||||
sortBy(
|
||||
Array.from(tagCountMap.entries()),
|
||||
([_tag, count]) => -count,
|
||||
).filter(([_tag, count]) => count >= 3),
|
||||
);
|
||||
|
||||
return {
|
||||
userId,
|
||||
imageCount,
|
||||
stepCount,
|
||||
pixelCount,
|
||||
pixelStepCount,
|
||||
tagCountMap,
|
||||
tagCountMap: tagCountObj,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
},
|
||||
|
|
|
@ -1,37 +1,23 @@
|
|||
import { Store } from "indexed_kv";
|
||||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
import { Static, t } from "elysia";
|
||||
import { db } from "./db.ts";
|
||||
|
||||
export const workerInstanceSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
// used for counting stats
|
||||
key: { type: "string" },
|
||||
// used for display
|
||||
name: { type: ["string", "null"] },
|
||||
sdUrl: { type: "string" },
|
||||
sdAuth: {
|
||||
type: ["object", "null"],
|
||||
properties: {
|
||||
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 const workerInstanceSchema = t.Object({
|
||||
key: t.String(),
|
||||
name: t.Nullable(t.String()),
|
||||
sdUrl: t.String(),
|
||||
sdAuth: t.Nullable(t.Object({
|
||||
user: t.String(),
|
||||
password: t.String(),
|
||||
})),
|
||||
lastOnlineTime: t.Optional(t.Number()),
|
||||
lastError: t.Optional(t.Object({
|
||||
message: t.String(),
|
||||
time: t.Number(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type WorkerInstance = jsonType<typeof workerInstanceSchema>;
|
||||
export type WorkerInstance = Static<typeof workerInstanceSchema>;
|
||||
|
||||
export const workerInstanceStore = new Store<WorkerInstance>(db, "workerInstances", {
|
||||
indices: {},
|
||||
|
|
|
@ -2,7 +2,7 @@ import { CommandContext } from "grammy";
|
|||
import { bold, fmt, FormattedString } from "grammy_parse_mode";
|
||||
import { distinctBy } from "std/collections/distinct_by.ts";
|
||||
import { error, info } from "std/log/mod.ts";
|
||||
import { getConfig } from "../app/config.ts";
|
||||
import { adminStore } from "../app/adminStore.ts";
|
||||
import { generationStore } from "../app/generationStore.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
|
@ -12,9 +12,9 @@ export async function broadcastCommand(ctx: CommandContext<ErisContext>) {
|
|||
return ctx.reply("I don't know who you are.");
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const adminEntry = await adminStore.get(["admins", ctx.from.id]);
|
||||
|
||||
if (!config.adminUsernames.includes(ctx.from.username)) {
|
||||
if (!adminEntry.versionstamp) {
|
||||
return ctx.reply("Only a bot admin can use this command.");
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { generationQueue } from "../app/generationQueue.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
|
||||
export async function cancelCommand(ctx: ErisContext) {
|
||||
|
@ -7,8 +8,11 @@ export async function cancelCommand(ctx: ErisContext) {
|
|||
.filter((job) => job.lockUntil < new Date())
|
||||
.filter((j) => j.state.from.id === ctx.from?.id);
|
||||
for (const job of userJobs) await generationQueue.deleteJob(job.id);
|
||||
await ctx.reply(`Cancelled ${userJobs.length} jobs`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
await ctx.reply(
|
||||
`Cancelled ${userJobs.length} jobs`,
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -129,7 +129,7 @@ async function img2img(
|
|||
chat: ctx.message.chat,
|
||||
requestMessage: ctx.message,
|
||||
replyMessage: replyMessage,
|
||||
}, { retryCount: 3, repeatDelayMs: 10_000 });
|
||||
}, { priority: 0, retryCount: 3, repeatDelayMs: 10_000 });
|
||||
|
||||
debug(`Generation (img2img) enqueued for ${formatUserChat(ctx.message)}`);
|
||||
}
|
||||
|
|
72
bot/mod.ts
72
bot/mod.ts
|
@ -4,8 +4,8 @@ import { hydrateReply, ParseModeFlavor } from "grammy_parse_mode";
|
|||
import { run, sequentialize } from "grammy_runner";
|
||||
import { error, info, warning } from "std/log/mod.ts";
|
||||
import { sessions } from "../api/sessionsRoute.ts";
|
||||
import { getConfig, setConfig } from "../app/config.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { broadcastCommand } from "./broadcastCommand.ts";
|
||||
import { cancelCommand } from "./cancelCommand.ts";
|
||||
import { img2imgCommand, img2imgQuestion } from "./img2imgCommand.ts";
|
||||
|
@ -19,11 +19,11 @@ interface SessionData {
|
|||
}
|
||||
|
||||
interface ErisChatData {
|
||||
language?: string;
|
||||
language?: string | undefined;
|
||||
}
|
||||
|
||||
interface ErisUserData {
|
||||
params?: Record<string, string>;
|
||||
params?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export type ErisContext =
|
||||
|
@ -94,10 +94,13 @@ bot.use(async (ctx, next) => {
|
|||
await next();
|
||||
} catch (err) {
|
||||
try {
|
||||
await ctx.reply(`Handling update failed: ${err}`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
await ctx.reply(
|
||||
`Handling update failed: ${err}`,
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
|
@ -112,7 +115,7 @@ bot.api.setMyDescription(
|
|||
bot.api.setMyCommands([
|
||||
{ command: "txt2img", description: "Generate image from text" },
|
||||
{ command: "img2img", description: "Generate image from image" },
|
||||
{ command: "pnginfo", description: "Show generation parameters of an image" },
|
||||
{ command: "pnginfo", description: "Try to extract prompt from raw file" },
|
||||
{ command: "queue", description: "Show the current queue" },
|
||||
{ command: "cancel", description: "Cancel all your requests" },
|
||||
]);
|
||||
|
@ -122,24 +125,33 @@ bot.command("start", async (ctx) => {
|
|||
const id = ctx.match.trim();
|
||||
const session = sessions.get(id);
|
||||
if (session == null) {
|
||||
await ctx.reply("Login failed: Invalid session ID", {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
await ctx.reply(
|
||||
"Login failed: Invalid session ID",
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
session.userId = ctx.from?.id;
|
||||
sessions.set(id, session);
|
||||
info(`User ${formatUserChat(ctx)} logged in`);
|
||||
// TODO: show link to web ui
|
||||
await ctx.reply("Login successful! You can now return to the WebUI.", {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
await ctx.reply(
|
||||
"Login successful! You can now return to the WebUI.",
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply("Hello! Use the /txt2img command to generate an image", {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
await ctx.reply(
|
||||
"Hello! Use the /txt2img command to generate an image",
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
bot.command("txt2img", txt2imgCommand);
|
||||
|
@ -157,35 +169,11 @@ bot.command("cancel", cancelCommand);
|
|||
|
||||
bot.command("broadcast", broadcastCommand);
|
||||
|
||||
bot.command("pause", async (ctx) => {
|
||||
if (!ctx.from?.username) return;
|
||||
const config = await getConfig();
|
||||
if (!config.adminUsernames.includes(ctx.from.username)) return;
|
||||
if (config.pausedReason != null) {
|
||||
return ctx.reply(`Already paused: ${config.pausedReason}`);
|
||||
}
|
||||
await setConfig({
|
||||
pausedReason: ctx.match || "No reason given",
|
||||
});
|
||||
warning(`Bot paused by ${ctx.from.first_name} because ${config.pausedReason}`);
|
||||
return ctx.reply("Paused");
|
||||
});
|
||||
|
||||
bot.command("resume", async (ctx) => {
|
||||
if (!ctx.from?.username) return;
|
||||
const config = await getConfig();
|
||||
if (!config.adminUsernames.includes(ctx.from.username)) return;
|
||||
if (config.pausedReason == null) return ctx.reply("Already running");
|
||||
await setConfig({ pausedReason: null });
|
||||
info(`Bot resumed by ${ctx.from.first_name}`);
|
||||
return ctx.reply("Resumed");
|
||||
});
|
||||
|
||||
bot.command("crash", () => {
|
||||
throw new Error("Crash command used");
|
||||
});
|
||||
|
||||
export async function runBot() {
|
||||
const runner = run(bot);
|
||||
const runner = run(bot, { runner: { silent: true } });
|
||||
await runner.task();
|
||||
}
|
||||
|
|
|
@ -1,12 +1,24 @@
|
|||
import { decode } from "png_chunk_text";
|
||||
import extractChunks from "png_chunks_extract";
|
||||
import * as ExifReader from "exifreader";
|
||||
|
||||
export function getPngInfo(pngData: Uint8Array): string | undefined {
|
||||
return extractChunks(pngData)
|
||||
.filter((chunk) => chunk.name === "tEXt")
|
||||
.map((chunk) => decode(chunk.data))
|
||||
.find((textChunk) => textChunk.keyword === "parameters")
|
||||
?.text;
|
||||
export function getPngInfo(pngData: ArrayBuffer): string | undefined {
|
||||
const info = ExifReader.load(pngData);
|
||||
|
||||
if (info.UserComment?.value && Array.isArray(info.UserComment.value)) {
|
||||
// JPEG image
|
||||
return String.fromCharCode(
|
||||
...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 {
|
||||
|
@ -25,7 +37,11 @@ interface PngInfoExtra extends PngInfo {
|
|||
upscale?: number;
|
||||
}
|
||||
|
||||
export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>, shouldParseSeed?: boolean): Partial<PngInfo> {
|
||||
export function parsePngInfo(
|
||||
pngInfo: string,
|
||||
baseParams?: Partial<PngInfo>,
|
||||
shouldParseSeed?: boolean,
|
||||
): Partial<PngInfo> {
|
||||
const tags = pngInfo.split(/[,;]+|\.+\s|\n/u);
|
||||
let part: "prompt" | "negative_prompt" | "params" = "prompt";
|
||||
const params: Partial<PngInfoExtra> = {};
|
||||
|
@ -34,7 +50,7 @@ export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>, sho
|
|||
for (const tag of tags) {
|
||||
const paramValuePair = tag.trim().match(/^(\w+\s*\w*):\s+(.*)$/u);
|
||||
if (paramValuePair) {
|
||||
const [, param, value] = paramValuePair;
|
||||
const [_match, param = "", value = ""] = paramValuePair;
|
||||
switch (param.replace(/\s+/u, "").toLowerCase()) {
|
||||
case "positiveprompt":
|
||||
case "positive":
|
||||
|
@ -67,7 +83,7 @@ export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>, sho
|
|||
case "size":
|
||||
case "resolution": {
|
||||
part = "params";
|
||||
const [width, height] = value.trim()
|
||||
const [width = 0, height = 0] = value.trim()
|
||||
.split(/\s*[x,]\s*/u, 2)
|
||||
.map((v) => v.trim())
|
||||
.map(Number);
|
||||
|
@ -103,9 +119,11 @@ export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>, sho
|
|||
part = "params";
|
||||
if (shouldParseSeed) {
|
||||
const seed = Number(value.trim());
|
||||
params.seed = seed;
|
||||
break;
|
||||
if (Number.isFinite(seed)) {
|
||||
params.seed = seed;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "model":
|
||||
case "modelhash":
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { CommandContext } from "grammy";
|
||||
import { bold, fmt } from "grammy_parse_mode";
|
||||
import { StatelessQuestion } from "grammy_stateless_question";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
import { getPngInfo, parsePngInfo } from "./parsePngInfo.ts";
|
||||
|
||||
|
@ -19,22 +20,31 @@ async function pnginfo(ctx: ErisContext, includeRepliedTo: boolean): Promise<voi
|
|||
const document = ctx.message?.document ||
|
||||
(includeRepliedTo ? ctx.message?.reply_to_message?.document : undefined);
|
||||
|
||||
if (document?.mime_type !== "image/png") {
|
||||
if (document?.mime_type !== "image/png" && document?.mime_type !== "image/jpeg") {
|
||||
await ctx.reply(
|
||||
"Please send me a PNG file." +
|
||||
"Please send me a PNG or JPEG file." +
|
||||
pnginfoQuestion.messageSuffixMarkdown(),
|
||||
{
|
||||
reply_markup: { force_reply: true, selective: true },
|
||||
parse_mode: "Markdown",
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
},
|
||||
omitUndef(
|
||||
{
|
||||
reply_markup: { force_reply: true, selective: true },
|
||||
parse_mode: "Markdown",
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
} as const,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await ctx.api.getFile(document.file_id);
|
||||
const buffer = await fetch(file.getUrl()).then((resp) => resp.arrayBuffer());
|
||||
const params = parsePngInfo(getPngInfo(new Uint8Array(buffer)) ?? "");
|
||||
const info = getPngInfo(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([
|
||||
`${params.prompt}\n`,
|
||||
|
@ -46,8 +56,11 @@ async function pnginfo(ctx: ErisContext, includeRepliedTo: boolean): Promise<voi
|
|||
params.width && params.height ? fmt`${bold("Size")}: ${params.width}x${params.height}` : "",
|
||||
]);
|
||||
|
||||
await ctx.reply(paramsText.text, {
|
||||
entities: paramsText.entities,
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
await ctx.reply(
|
||||
paramsText.text,
|
||||
omitUndef({
|
||||
entities: paramsText.entities,
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
import { CommandContext } from "grammy";
|
||||
import { bold, fmt } from "grammy_parse_mode";
|
||||
import { activeGenerationWorkers, generationQueue } from "../app/generationQueue.ts";
|
||||
import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
import { workerInstanceStore } from "../app/workerInstanceStore.ts";
|
||||
import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
|
||||
export async function queueCommand(ctx: CommandContext<ErisContext>) {
|
||||
let formattedMessage = await getMessageText();
|
||||
const queueMessage = await ctx.replyFmt(formattedMessage, {
|
||||
disable_notification: true,
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
const queueMessage = await ctx.replyFmt(
|
||||
formattedMessage,
|
||||
omitUndef({
|
||||
disable_notification: true,
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
}),
|
||||
);
|
||||
handleFutureUpdates().catch(() => undefined);
|
||||
|
||||
async function getMessageText() {
|
||||
|
|
|
@ -6,6 +6,7 @@ import { generationQueue } from "../app/generationQueue.ts";
|
|||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
import { getPngInfo, parsePngInfo, PngInfo } from "./parsePngInfo.ts";
|
||||
import { adminStore } from "../app/adminStore.ts";
|
||||
|
||||
export const txt2imgQuestion = new StatelessQuestion<ErisContext>(
|
||||
"txt2img",
|
||||
|
@ -29,6 +30,7 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
|||
}
|
||||
|
||||
const config = await getConfig();
|
||||
let priority = 0;
|
||||
|
||||
if (config.pausedReason != null) {
|
||||
await ctx.reply(`I'm paused: ${config.pausedReason || "No reason given"}`, {
|
||||
|
@ -37,20 +39,26 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
|||
return;
|
||||
}
|
||||
|
||||
const jobs = await generationQueue.getAllJobs();
|
||||
if (jobs.length >= config.maxJobs) {
|
||||
await ctx.reply(`The queue is full. Try again later. (Max queue size: ${config.maxJobs})`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const adminEntry = await adminStore.get(["admins", ctx.from.id]);
|
||||
|
||||
const userJobs = jobs.filter((job) => job.state.from.id === ctx.message?.from?.id);
|
||||
if (userJobs.length >= config.maxUserJobs) {
|
||||
await ctx.reply(`You already have ${userJobs.length} jobs in queue. Try again later.`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
return;
|
||||
if (adminEntry.versionstamp) {
|
||||
priority = 1;
|
||||
} else {
|
||||
const jobs = await generationQueue.getAllJobs();
|
||||
if (jobs.length >= config.maxJobs) {
|
||||
await ctx.reply(`The queue is full. Try again later. (Max queue size: ${config.maxJobs})`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userJobs = jobs.filter((job) => job.state.from.id === ctx.message?.from?.id);
|
||||
if (userJobs.length >= config.maxUserJobs) {
|
||||
await ctx.reply(`You already have ${userJobs.length} jobs in the queue. Try again later.`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let params: Partial<PngInfo> = {};
|
||||
|
@ -60,17 +68,26 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
|||
if (includeRepliedTo && repliedToMsg?.document?.mime_type === "image/png") {
|
||||
const file = await ctx.api.getFile(repliedToMsg.document.file_id);
|
||||
const buffer = await fetch(file.getUrl()).then((resp) => resp.arrayBuffer());
|
||||
params = parsePngInfo(getPngInfo(new Uint8Array(buffer)) ?? "", params);
|
||||
params = parsePngInfo(getPngInfo(buffer) ?? "", params);
|
||||
}
|
||||
|
||||
const repliedToText = repliedToMsg?.text || repliedToMsg?.caption;
|
||||
if (includeRepliedTo && repliedToText) {
|
||||
const isReply = includeRepliedTo && repliedToText;
|
||||
|
||||
if (isReply) {
|
||||
// TODO: remove bot command from replied to text
|
||||
params = parsePngInfo(repliedToText, params);
|
||||
}
|
||||
|
||||
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) {
|
||||
await ctx.reply(
|
||||
"Please tell me what you want to see." +
|
||||
|
@ -94,7 +111,7 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
|||
chat: ctx.message.chat,
|
||||
requestMessage: ctx.message,
|
||||
replyMessage: replyMessage,
|
||||
}, { retryCount: 3, retryDelayMs: 10_000 });
|
||||
}, { retryCount: 3, retryDelayMs: 10_000, priority: priority });
|
||||
|
||||
debug(`Generation (txt2img) enqueued for ${formatUserChat(ctx.message)}`);
|
||||
}
|
||||
|
|
57
deno.json
57
deno.json
|
@ -1,32 +1,37 @@
|
|||
{
|
||||
"tasks": {
|
||||
"start": "deno run --unstable --allow-env --allow-read --allow-write --allow-net main.ts",
|
||||
"check": "deno check --unstable main.ts && deno check --unstable ui/main.tsx"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"jsx": "react"
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"jsx": "react",
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100
|
||||
},
|
||||
"imports": {
|
||||
"@date-fns/utc": "https://cdn.skypack.dev/@date-fns/utc@1.1.0?dts",
|
||||
"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/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",
|
||||
"grammy": "https://lib.deno.dev/x/grammy@1/mod.ts",
|
||||
"grammy_autoquote": "https://lib.deno.dev/x/grammy_autoquote@1/mod.ts",
|
||||
"grammy_files": "https://lib.deno.dev/x/grammy_files@1/mod.ts",
|
||||
"grammy_parse_mode": "https://lib.deno.dev/x/grammy_parse_mode@1/mod.ts",
|
||||
"grammy_runner": "https://lib.deno.dev/x/grammy_runner@2/mod.ts",
|
||||
"grammy_stateless_question": "https://lib.deno.dev/x/grammy_stateless_question_alpha@3/mod.ts",
|
||||
"grammy_types": "https://lib.deno.dev/x/grammy_types@3/mod.ts",
|
||||
"grammy": "https://lib.deno.dev/x/grammy@1/mod.ts",
|
||||
"indexed_kv": "https://deno.land/x/indexed_kv@v0.5.0/mod.ts",
|
||||
"indexed_kv": "https://deno.land/x/indexed_kv@v0.6.1/mod.ts",
|
||||
"kvfs": "https://deno.land/x/kvfs@v0.1.0/mod.ts",
|
||||
"kvmq": "https://deno.land/x/kvmq@v0.3.0/mod.ts",
|
||||
"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-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-intl": "https://esm.sh/react-intl@6.4.7?external=react&dev",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@6.16.0?external=react&dev",
|
||||
"reroute": "https://deno.land/x/reroute@v0.1.0/mod.ts",
|
||||
"serve_spa": "https://deno.land/x/serve_spa@v0.2.0/mod.ts",
|
||||
"std/async/": "https://deno.land/std@0.201.0/async/",
|
||||
|
@ -36,19 +41,23 @@
|
|||
"std/fmt/": "https://deno.land/std@0.202.0/fmt/",
|
||||
"std/log/": "https://deno.land/std@0.201.0/log/",
|
||||
"std/path/": "https://deno.land/std@0.204.0/path/",
|
||||
"t_rest/server": "https://esm.sh/ty-rest@0.4.0/server?dev",
|
||||
"ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts",
|
||||
|
||||
"@twind/core": "https://esm.sh/@twind/core@1.1.3?dev",
|
||||
"@twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4?dev",
|
||||
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
|
||||
"react-flip-move": "https://esm.sh/react-flip-move@3.0.5?dev",
|
||||
"react-intl": "https://esm.sh/react-intl@6.4.7?external=react&alias=@types/react:react&dev",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@6.16.0?dev",
|
||||
"react": "https://esm.sh/react@18.2.0?dev",
|
||||
"swr": "https://esm.sh/swr@2.2.4?dev",
|
||||
"swr/mutation": "https://esm.sh/swr@2.2.4/mutation?dev",
|
||||
"t_rest/client": "https://esm.sh/ty-rest@0.4.0/client?dev",
|
||||
"use-local-storage": "https://esm.sh/use-local-storage@3.0.0?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",
|
||||
"twind/core": "https://esm.sh/@twind/core@1.1.3",
|
||||
"twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4",
|
||||
"ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"exclude": [
|
||||
"require-await"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"check": "deno check --unstable-kv main.ts && deno check --unstable-kv ui/main.tsx",
|
||||
"generate": "deno run npm:openapi-typescript http://localhost:7861/openapi.json -o app/sdApi.ts",
|
||||
"start": "deno run --unstable-kv --allow-env --allow-read=. --allow-write=db --allow-net main.ts",
|
||||
"test": "deno test"
|
||||
}
|
||||
}
|
||||
|
|
362
deno.lock
362
deno.lock
|
@ -1,362 +0,0 @@
|
|||
{
|
||||
"version": "3",
|
||||
"packages": {
|
||||
"specifiers": {
|
||||
"npm:telegram-format@2": "npm:telegram-format@2.1.0"
|
||||
},
|
||||
"npm": {
|
||||
"telegram-format@2.1.0": {
|
||||
"integrity": "sha512-V2thkhKzcNVL26h/ANeat/Z+AXBaDGoizs4cab6Kpq/w+d+Ai6M/AFFkuBwvCkZXqb+7UW2vt3Dko5+kKcOIHg==",
|
||||
"dependencies": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"redirects": {
|
||||
"https://cdn.skypack.dev/-/@date-fns/utc@v1.1.0-EKCmNY9452DyFBJfVVmZ/dist=es2019,mode=types/date.d.ts": "https://cdn.skypack.dev/-/@date-fns/utc@v1.1.0-EKCmNY9452DyFBJfVVmZ/dist=es2019,mode=types/date/index.d.ts",
|
||||
"https://deno.land/std/path/mod.ts": "https://deno.land/std@0.204.0/path/mod.ts",
|
||||
"https://esm.sh/@grammyjs/types@2": "https://esm.sh/@grammyjs/types@2.12.1",
|
||||
"https://esm.sh/@grammyjs/types@v2": "https://esm.sh/@grammyjs/types@2.0.0",
|
||||
"https://esm.sh/@swc/core@1.2.212/types.d.ts": "https://esm.sh/v133/@swc/core@1.2.212/types.d.ts",
|
||||
"https://esm.sh/v128/@types/react@~18.2/index.d.ts": "https://esm.sh/v128/@types/react@18.2.25/index.d.ts",
|
||||
"https://esm.sh/v133/@types/png-chunk-text@~1.0/index.d.ts": "https://esm.sh/v133/@types/png-chunk-text@1.0.1/index.d.ts",
|
||||
"https://esm.sh/v133/@types/png-chunks-extract@~1.0/index.d.ts": "https://esm.sh/v133/@types/png-chunks-extract@1.0.0/index.d.ts",
|
||||
"https://esm.sh/v133/@types/react-dom@~18.2/client~.d.ts": "https://esm.sh/v133/@types/react-dom@18.2.13/client~.d.ts",
|
||||
"https://lib.deno.dev/x/grammy@1/mod.ts": "https://deno.land/x/grammy@v1.19.1/mod.ts",
|
||||
"https://lib.deno.dev/x/grammy@^1.0/mod.ts": "https://deno.land/x/grammy@v1.19.1/mod.ts",
|
||||
"https://lib.deno.dev/x/grammy@v1/mod.ts": "https://deno.land/x/grammy@v1.19.1/mod.ts",
|
||||
"https://lib.deno.dev/x/grammy@v1/types.ts": "https://deno.land/x/grammy@v1.19.1/types.ts",
|
||||
"https://lib.deno.dev/x/grammy_files@1/mod.ts": "https://deno.land/x/grammy_files@v1.0.4/mod.ts",
|
||||
"https://lib.deno.dev/x/grammy_parse_mode@1/mod.ts": "https://deno.land/x/grammy_parse_mode@1.8.1/mod.ts",
|
||||
"https://lib.deno.dev/x/grammy_runner@2/mod.ts": "https://deno.land/x/grammy_runner@v2.0.3/mod.ts",
|
||||
"https://lib.deno.dev/x/grammy_stateless_question_alpha@3/mod.ts": "https://deno.land/x/grammy_stateless_question_alpha@v3.0.4/mod.ts",
|
||||
"https://lib.deno.dev/x/grammy_types@3/mod.ts": "https://deno.land/x/grammy_types@v3.3.0/mod.ts"
|
||||
},
|
||||
"remote": {
|
||||
"https://cdn.skypack.dev/-/@date-fns/utc@v1.1.0-EKCmNY9452DyFBJfVVmZ/dist=es2019,mode=imports/optimized/@date-fns/utc.js": "f3566d499010a4872640fc2c5f539e98d490d794f43e597a2da9aadf03b2c050",
|
||||
"https://cdn.skypack.dev/-/@date-fns/utc@v1.1.0-EKCmNY9452DyFBJfVVmZ/dist=es2019,mode=imports/optimized/@date-fns/utc/date.js": "bd4ef50c3a846971a4b001bfdbfee22e9b523a4faaaa776c1001b5c8b450a025",
|
||||
"https://cdn.skypack.dev/-/@date-fns/utc@v1.1.0-EKCmNY9452DyFBJfVVmZ/dist=es2019,mode=imports/optimized/@date-fns/utc/date/mini.js": "2d7a4c546f921006542c2f83b76a5d1a94c670f9c885382d998054f627aadd5a",
|
||||
"https://cdn.skypack.dev/-/date-fns@v2.30.0-E7SpvwCL3wRUK0Q9mmOa/dist=es2019,mode=imports/optimized/date-fns.js": "9e81dde88a23d207bf9c40c8350525c9115eeede74000c9f94fcd4a65ce64865",
|
||||
"https://cdn.skypack.dev/-/debug@v4.3.4-o4liVvMlOnQWbLSYZMXw/dist=es2019,mode=imports/optimized/debug.js": "671100993996e39b501301a87000607916d4d2d9f8fc8e9c5200ae5ba64a1389",
|
||||
"https://cdn.skypack.dev/-/ms@v2.1.2-giBDZ1IA5lmQ3ZXaa87V/dist=es2019,mode=imports/optimized/ms.js": "fd88e2d51900437011f1ad232f3393ce97db1b87a7844b3c58dd6d65562c1276",
|
||||
"https://cdn.skypack.dev/@date-fns/utc@1.1.0?dts": "9fafa18de74e61386a79482ca58b766c0be1f8fd10bdc931ef26bd4b8cdac91e",
|
||||
"https://cdn.skypack.dev/date-fns@2.30.0?dts": "9cf78eb65f86f7b699e241a1e0ff94f3878d693e517e21ddc7fe727e3b479058",
|
||||
"https://cdn.skypack.dev/debug@4.3.4": "7b1d010cc930f71b940ba5941da055bc181115229e29de7214bdb4425c68ea76",
|
||||
"https://deno.land/std@0.135.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
|
||||
"https://deno.land/std@0.135.0/_util/os.ts": "49b92edea1e82ba295ec946de8ffd956ed123e2948d9bd1d3e901b04e4307617",
|
||||
"https://deno.land/std@0.135.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d",
|
||||
"https://deno.land/std@0.135.0/bytes/equals.ts": "a60ef9f01fb6e06a4e0343fc44d53d39d12dd66bc3f09ac8e6eb9cc1a6335e48",
|
||||
"https://deno.land/std@0.135.0/bytes/mod.ts": "4cef6fe8f0de217b9babbcbb0a566402b667f18a8e6d094a45e5fb3fc1afff70",
|
||||
"https://deno.land/std@0.135.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37",
|
||||
"https://deno.land/std@0.135.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b",
|
||||
"https://deno.land/std@0.135.0/io/files.ts": "d199ef64e918a256320ba8d8d44ae91de87c9077df8f8d6cca013f1b9fbbe285",
|
||||
"https://deno.land/std@0.135.0/io/mod.ts": "1a4e8d19d42745fb2ff68d6ffa801657a4a15713bf7e7173df2da4737f5c5450",
|
||||
"https://deno.land/std@0.135.0/io/readers.ts": "15062a8863e6df8503ac6629924d04c5e65cf3da15997470525e705831a810c8",
|
||||
"https://deno.land/std@0.135.0/io/streams.ts": "988a19155b52161f0035ce539e2f1d12edbc4c389fa7633da832a64e6edbe1a0",
|
||||
"https://deno.land/std@0.135.0/io/util.ts": "078da53bba767bec0d45f7da44411f6dbf269e51ef7fcfea5e3714e04681c674",
|
||||
"https://deno.land/std@0.135.0/io/writers.ts": "5db9995d2afc7ed391c88c6b441457df6fad6a0b09653e54c1dcd0387ab947fd",
|
||||
"https://deno.land/std@0.135.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3",
|
||||
"https://deno.land/std@0.135.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09",
|
||||
"https://deno.land/std@0.135.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b",
|
||||
"https://deno.land/std@0.135.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633",
|
||||
"https://deno.land/std@0.135.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee",
|
||||
"https://deno.land/std@0.135.0/path/mod.ts": "4275129bb766f0e475ecc5246aa35689eeade419d72a48355203f31802640be7",
|
||||
"https://deno.land/std@0.135.0/path/posix.ts": "293cdaec3ecccec0a9cc2b534302dfe308adb6f10861fa183275d6695faace44",
|
||||
"https://deno.land/std@0.135.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9",
|
||||
"https://deno.land/std@0.135.0/path/win32.ts": "31811536855e19ba37a999cd8d1b62078235548d67902ece4aa6b814596dd757",
|
||||
"https://deno.land/std@0.135.0/streams/conversion.ts": "712585bfa0172a97fb68dd46e784ae8ad59d11b88079d6a4ab098ff42e697d21",
|
||||
"https://deno.land/std@0.135.0/testing/_diff.ts": "9d849cd6877694152e01775b2d93f9d6b7aef7e24bfe3bfafc4d7a1ac8e9f392",
|
||||
"https://deno.land/std@0.135.0/testing/asserts.ts": "b0ef969032882b1f7eb1c7571e313214baa1485f7b61cf35807b2434e254365c",
|
||||
"https://deno.land/std@0.186.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8",
|
||||
"https://deno.land/std@0.201.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
|
||||
"https://deno.land/std@0.201.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
|
||||
"https://deno.land/std@0.201.0/async/abortable.ts": "fd682fa46f3b7b16b4606a5ab52a7ce309434b76f820d3221bdfb862719a15d7",
|
||||
"https://deno.land/std@0.201.0/async/deadline.ts": "58f72a3cc0fcb731b2cc055ba046f4b5be3349ff6bf98f2e793c3b969354aab2",
|
||||
"https://deno.land/std@0.201.0/async/debounce.ts": "adab11d04ca38d699444ac8a9d9856b4155e8dda2afd07ce78276c01ea5a4332",
|
||||
"https://deno.land/std@0.201.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8",
|
||||
"https://deno.land/std@0.201.0/async/delay.ts": "a6142eb44cdd856b645086af2b811b1fcce08ec06bb7d50969e6a872ee9b8659",
|
||||
"https://deno.land/std@0.201.0/async/mod.ts": "f04344fa21738e5ad6bea37a6bfffd57c617c2d372bb9f9dcfd118a1b622e576",
|
||||
"https://deno.land/std@0.201.0/async/mux_async_iterator.ts": "70c7f2ee4e9466161350473ad61cac0b9f115cff4c552eaa7ef9d50c4cbb4cc9",
|
||||
"https://deno.land/std@0.201.0/async/pool.ts": "47c1841cfa9c036144943d11747ddd44064f5baf8cb7ece25473ba873c6aceb0",
|
||||
"https://deno.land/std@0.201.0/async/retry.ts": "296fb9c323e1325a69bee14ba947e7da7409a8dd9dd646d70cb51ea0d301f24e",
|
||||
"https://deno.land/std@0.201.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757",
|
||||
"https://deno.land/std@0.201.0/bytes/copy.ts": "939d89e302a9761dcf1d9c937c7711174ed74c59eef40a1e4569a05c9de88219",
|
||||
"https://deno.land/std@0.201.0/dotenv/load.ts": "0636983549b98f29ab75c9a22a42d9723f0a389ece5498fe971e7bb2556a12e2",
|
||||
"https://deno.land/std@0.201.0/dotenv/mod.ts": "1da8c6d0e7f7d8a5c2b19400b763bc11739df24acec235dda7ea2cfd3d300057",
|
||||
"https://deno.land/std@0.201.0/fmt/colors.ts": "87544aa2bc91087bb37f9c077970c85bfb041b48e4c37356129d7b450a415b6f",
|
||||
"https://deno.land/std@0.201.0/fs/exists.ts": "cb59a853d84871d87acab0e7936a4dac11282957f8e195102c5a7acb42546bb8",
|
||||
"https://deno.land/std@0.201.0/io/buf_writer.ts": "48c33c8f00b61dcbc7958706741cec8e59810bd307bc6a326cbd474fe8346dfd",
|
||||
"https://deno.land/std@0.201.0/log/handlers.ts": "3a0883f65567f59a9a88e44c972b24b924621bc28ead91af11d7a6da93c4a64c",
|
||||
"https://deno.land/std@0.201.0/log/levels.ts": "6309147664e9e008cd6671610f2505c4c95f181f6bae4816a84b33e0aec66859",
|
||||
"https://deno.land/std@0.201.0/log/logger.ts": "180c50a07c43a556dc5794e913c82946399e89d683201d01c8f0091e1e4ae3fc",
|
||||
"https://deno.land/std@0.201.0/log/mod.ts": "a274d2129c8d08d4c96e0fb165a595e6c730b5130b437a9ce04364156bfe955a",
|
||||
"https://deno.land/std@0.202.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
|
||||
"https://deno.land/std@0.202.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
|
||||
"https://deno.land/std@0.202.0/bytes/bytes_list.ts": "ecf5098c230b793970f43c06e8f30d70b937c031658365aeb3de9a8ae4d406a3",
|
||||
"https://deno.land/std@0.202.0/bytes/concat.ts": "d26d6f3d7922e6d663dacfcd357563b7bf4a380ce5b9c2bbe0c8586662f25ce2",
|
||||
"https://deno.land/std@0.202.0/bytes/copy.ts": "939d89e302a9761dcf1d9c937c7711174ed74c59eef40a1e4569a05c9de88219",
|
||||
"https://deno.land/std@0.202.0/bytes/ends_with.ts": "4228811ebc71615d27f065c54b5e815ec1972538772b0f413c0efe05245b472e",
|
||||
"https://deno.land/std@0.202.0/bytes/equals.ts": "fc190cce412b2136979181b163ec7e05f7e7a947e39102eee4b8c0d62519ddf9",
|
||||
"https://deno.land/std@0.202.0/bytes/includes_needle.ts": "76a8163126fb2f8bf86fd7f22192c3bb04bf6a20b987a095127c2ca08adf3ba6",
|
||||
"https://deno.land/std@0.202.0/bytes/index_of_needle.ts": "9c06610e9611b5647ac25952e71a22e09227c9f1b8cbeeb33399bf8bf8a7f649",
|
||||
"https://deno.land/std@0.202.0/bytes/last_index_of_needle.ts": "f1602f221c3b678bc4f1e1c88a70a15ab7da32c21751dbbc6c957c956951d784",
|
||||
"https://deno.land/std@0.202.0/bytes/mod.ts": "e869bba1e7a2e3a9cc6c2d55471888429a544e70a840c087672e656e7ba21815",
|
||||
"https://deno.land/std@0.202.0/bytes/repeat.ts": "6f5e490d8d72bcbf8d84a6bb04690b9b3eb5822c5a11687bca73a2318a842294",
|
||||
"https://deno.land/std@0.202.0/bytes/starts_with.ts": "3e607a70c9c09f5140b7a7f17a695221abcc7244d20af3eb47ccbb63f5885135",
|
||||
"https://deno.land/std@0.202.0/collections/_utils.ts": "5114abc026ddef71207a79609b984614e66a63a4bda17d819d56b0e72c51527e",
|
||||
"https://deno.land/std@0.202.0/collections/deep_merge.ts": "9db788ba56cb05b65c77166b789e58e125dff159b7f41bf4d19dc1cba19ecb8b",
|
||||
"https://deno.land/std@0.202.0/collections/distinct_by.ts": "3afe11d81eafb30c7c9dbf568d94357f3d88153292c00671b72cd695deae6602",
|
||||
"https://deno.land/std@0.202.0/collections/max_by.ts": "9d5940986aac51b2e4feaebef9cd8bf6e1eceeee5edcf3303e334b737f99520d",
|
||||
"https://deno.land/std@0.202.0/encoding/base64.ts": "144ae6234c1fbe5b68666c711dc15b1e9ee2aef6d42b3b4345bf9a6c91d70d0d",
|
||||
"https://deno.land/std@0.202.0/flags/mod.ts": "0948466fc437f017f00c0b972a422b3dc3317a790bcf326429d23182977eaf9f",
|
||||
"https://deno.land/std@0.202.0/fmt/bytes.ts": "f29cf69e0791d375f9f5d94ae1f0641e5a03b975f32ddf86d70f70fdf37e7b6a",
|
||||
"https://deno.land/std@0.202.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9",
|
||||
"https://deno.land/std@0.202.0/fmt/duration.ts": "7e73e2e7aa82013ea5885efd65e331c6b99506b3977bb9edcd2b6745ce3212e0",
|
||||
"https://deno.land/std@0.202.0/http/etag.ts": "807382795850cde5c437c74bcc09392bc0fc56de348fc1271f383f4b28935b9f",
|
||||
"https://deno.land/std@0.202.0/http/file_server.ts": "6f5c4a28c36995f31544abb49b86bee6e7a2d34664cac3936ff08ccad1682d85",
|
||||
"https://deno.land/std@0.202.0/http/http_status.ts": "8a7bcfe3ac025199ad804075385e57f63d055b2aed539d943ccc277616d6f932",
|
||||
"https://deno.land/std@0.202.0/http/util.ts": "4cf044067febaa26d0830e356b0f3a5f76d701a60d7ff7a516fad7b192f4c3a7",
|
||||
"https://deno.land/std@0.202.0/media_types/_db.ts": "7606d83e31f23ce1a7968cbaee852810c2cf477903a095696cdc62eaab7ce570",
|
||||
"https://deno.land/std@0.202.0/media_types/_util.ts": "0879b04cc810ff18d3dcd97d361e03c9dfb29f67d7fc4a9c6c9d387282ef5fe8",
|
||||
"https://deno.land/std@0.202.0/media_types/content_type.ts": "ad98a5aa2d95f5965b2796072284258710a25e520952376ed432b0937ce743bc",
|
||||
"https://deno.land/std@0.202.0/media_types/format_media_type.ts": "f5e1073c05526a6f5a516ac5c5587a1abd043bf1039c71cde1166aa4328c8baf",
|
||||
"https://deno.land/std@0.202.0/media_types/get_charset.ts": "18b88274796fda5d353806bf409eb1d2ddb3f004eb4bd311662c4cdd8ac173db",
|
||||
"https://deno.land/std@0.202.0/media_types/parse_media_type.ts": "31ccf2388ffab31b49500bb89fa0f5de189c8897e2ee6c9954f207637d488211",
|
||||
"https://deno.land/std@0.202.0/media_types/type_by_extension.ts": "8c210d4e28ea426414dd8c61146eefbcc7e091a89ccde54bbbe883a154856afd",
|
||||
"https://deno.land/std@0.202.0/media_types/vendor/mime-db.v1.52.0.ts": "6925bbcae81ca37241e3f55908d0505724358cda3384eaea707773b2c7e99586",
|
||||
"https://deno.land/std@0.202.0/path/_basename.ts": "057d420c9049821f983f784fd87fa73ac471901fb628920b67972b0f44319343",
|
||||
"https://deno.land/std@0.202.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0",
|
||||
"https://deno.land/std@0.202.0/path/_extname.ts": "eaaa5aae1acf1f03254d681bd6a8ce42a9cb5b7ff2213a9d4740e8ab31283664",
|
||||
"https://deno.land/std@0.202.0/path/_join.ts": "815f5e85b042285175b1492dd5781240ce126c23bd97bad6b8211fe7129c538e",
|
||||
"https://deno.land/std@0.202.0/path/_normalize.ts": "a19ec8706b2707f9dd974662a5cd89fad438e62ab1857e08b314a8eb49a34d81",
|
||||
"https://deno.land/std@0.202.0/path/_os.ts": "30b0c2875f360c9296dbe6b7f2d528f0f9c741cecad2e97f803f5219e91b40a2",
|
||||
"https://deno.land/std@0.202.0/path/_relative.ts": "27bdeffb5311a47d85be26d37ad1969979359f7636c5cd9fcf05dcd0d5099dc5",
|
||||
"https://deno.land/std@0.202.0/path/_resolve.ts": "7a3616f1093735ed327e758313b79c3c04ea921808ca5f19ddf240cb68d0adf6",
|
||||
"https://deno.land/std@0.202.0/path/_util.ts": "4e191b1bac6b3bf0c31aab42e5ca2e01a86ab5a0d2e08b75acf8585047a86221",
|
||||
"https://deno.land/std@0.202.0/path/basename.ts": "bdfa5a624c6a45564dc6758ef2077f2822978a6dbe77b0a3514f7d1f81362930",
|
||||
"https://deno.land/std@0.202.0/path/extname.ts": "62c4b376300795342fe1e4746c0de518b4dc9c4b0b4617bfee62a2973a9555cf",
|
||||
"https://deno.land/std@0.202.0/path/join.ts": "31c5419f23d91655b08ec7aec403f4e4cd1a63d39e28f6e42642ea207c2734f8",
|
||||
"https://deno.land/std@0.202.0/path/relative.ts": "8bedac226afd360afc45d451a6c29fabceaf32978526bcb38e0c852661f66c61",
|
||||
"https://deno.land/std@0.202.0/path/resolve.ts": "133161e4949fc97f9ca67988d51376b0f5eef8968a6372325ab84d39d30b80dc",
|
||||
"https://deno.land/std@0.202.0/path/separator.ts": "40a3e9a4ad10bef23bc2cd6c610291b6c502a06237c2c4cd034a15ca78dedc1f",
|
||||
"https://deno.land/std@0.202.0/streams/_common.ts": "3b2c1f0287ce2ad51fff4091a7d0f48375c85b0ec341468e76d5ac13bb0014dd",
|
||||
"https://deno.land/std@0.202.0/streams/byte_slice_stream.ts": "c46d7c74836fc8c1a9acd9fe211cbe1bbaaee1b36087c834fb03af4991135c3a",
|
||||
"https://deno.land/std@0.202.0/streams/iterate_reader.ts": "3b42d3056c8ccade561f1c7ac22d5e671e745933d9f9168fd3b5913588d911c3",
|
||||
"https://deno.land/std@0.202.0/version.ts": "a39aa19f482555b66c041c6317bd8ce849401a3b580bd12e80fe0adf647b0ad1",
|
||||
"https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
|
||||
"https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
|
||||
"https://deno.land/std@0.203.0/flags/mod.ts": "0948466fc437f017f00c0b972a422b3dc3317a790bcf326429d23182977eaf9f",
|
||||
"https://deno.land/std@0.203.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0",
|
||||
"https://deno.land/std@0.203.0/path/_extname.ts": "eaaa5aae1acf1f03254d681bd6a8ce42a9cb5b7ff2213a9d4740e8ab31283664",
|
||||
"https://deno.land/std@0.203.0/path/_join.ts": "815f5e85b042285175b1492dd5781240ce126c23bd97bad6b8211fe7129c538e",
|
||||
"https://deno.land/std@0.203.0/path/_normalize.ts": "a19ec8706b2707f9dd974662a5cd89fad438e62ab1857e08b314a8eb49a34d81",
|
||||
"https://deno.land/std@0.203.0/path/_os.ts": "30b0c2875f360c9296dbe6b7f2d528f0f9c741cecad2e97f803f5219e91b40a2",
|
||||
"https://deno.land/std@0.203.0/path/_util.ts": "4e191b1bac6b3bf0c31aab42e5ca2e01a86ab5a0d2e08b75acf8585047a86221",
|
||||
"https://deno.land/std@0.203.0/path/extname.ts": "62c4b376300795342fe1e4746c0de518b4dc9c4b0b4617bfee62a2973a9555cf",
|
||||
"https://deno.land/std@0.203.0/path/join.ts": "31c5419f23d91655b08ec7aec403f4e4cd1a63d39e28f6e42642ea207c2734f8",
|
||||
"https://deno.land/std@0.204.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
|
||||
"https://deno.land/std@0.204.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
|
||||
"https://deno.land/std@0.204.0/path/_common/assert_path.ts": "061e4d093d4ba5aebceb2c4da3318bfe3289e868570e9d3a8e327d91c2958946",
|
||||
"https://deno.land/std@0.204.0/path/_common/basename.ts": "0d978ff818f339cd3b1d09dc914881f4d15617432ae519c1b8fdc09ff8d3789a",
|
||||
"https://deno.land/std@0.204.0/path/_common/common.ts": "9e4233b2eeb50f8b2ae10ecc2108f58583aea6fd3e8907827020282dc2b76143",
|
||||
"https://deno.land/std@0.204.0/path/_common/constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0",
|
||||
"https://deno.land/std@0.204.0/path/_common/dirname.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397",
|
||||
"https://deno.land/std@0.204.0/path/_common/format.ts": "11aa62e316dfbf22c126917f5e03ea5fe2ee707386555a8f513d27ad5756cf96",
|
||||
"https://deno.land/std@0.204.0/path/_common/from_file_url.ts": "ef1bf3197d2efbf0297a2bdbf3a61d804b18f2bcce45548ae112313ec5be3c22",
|
||||
"https://deno.land/std@0.204.0/path/_common/glob_to_reg_exp.ts": "5c3c2b79fc2294ec803d102bd9855c451c150021f452046312819fbb6d4dc156",
|
||||
"https://deno.land/std@0.204.0/path/_common/is_glob.ts": "567dce5c6656bdedfc6b3ee6c0833e1e4db2b8dff6e62148e94a917f289c06ad",
|
||||
"https://deno.land/std@0.204.0/path/_common/normalize.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397",
|
||||
"https://deno.land/std@0.204.0/path/_common/normalize_string.ts": "88c472f28ae49525f9fe82de8c8816d93442d46a30d6bb5063b07ff8a89ff589",
|
||||
"https://deno.land/std@0.204.0/path/_common/relative.ts": "1af19d787a2a84b8c534cc487424fe101f614982ae4851382c978ab2216186b4",
|
||||
"https://deno.land/std@0.204.0/path/_common/strip_trailing_separators.ts": "7ffc7c287e97bdeeee31b155828686967f222cd73f9e5780bfe7dfb1b58c6c65",
|
||||
"https://deno.land/std@0.204.0/path/_common/to_file_url.ts": "a8cdd1633bc9175b7eebd3613266d7c0b6ae0fb0cff24120b6092ac31662f9ae",
|
||||
"https://deno.land/std@0.204.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b",
|
||||
"https://deno.land/std@0.204.0/path/_os.ts": "30b0c2875f360c9296dbe6b7f2d528f0f9c741cecad2e97f803f5219e91b40a2",
|
||||
"https://deno.land/std@0.204.0/path/basename.ts": "04bb5ef3e86bba8a35603b8f3b69537112cdd19ce64b77f2522006da2977a5f3",
|
||||
"https://deno.land/std@0.204.0/path/common.ts": "f4d061c7d0b95a65c2a1a52439edec393e906b40f1caf4604c389fae7caa80f5",
|
||||
"https://deno.land/std@0.204.0/path/dirname.ts": "88a0a71c21debafc4da7a4cd44fd32e899462df458fbca152390887d41c40361",
|
||||
"https://deno.land/std@0.204.0/path/extname.ts": "2da4e2490f3b48b7121d19fb4c91681a5e11bd6bd99df4f6f47d7a71bb6ecdf2",
|
||||
"https://deno.land/std@0.204.0/path/format.ts": "3457530cc85d1b4bab175f9ae73998b34fd456c830d01883169af0681b8894fb",
|
||||
"https://deno.land/std@0.204.0/path/from_file_url.ts": "e7fa233ea1dff9641e8d566153a24d95010110185a6f418dd2e32320926043f8",
|
||||
"https://deno.land/std@0.204.0/path/glob.ts": "9c77cf47db1d786e2ebf66670824d03fd84ecc7c807cac24441eb9d5cb6a2986",
|
||||
"https://deno.land/std@0.204.0/path/is_absolute.ts": "67232b41b860571c5b7537f4954c88d86ae2ba45e883ee37d3dec27b74909d13",
|
||||
"https://deno.land/std@0.204.0/path/join.ts": "98d3d76c819af4a11a81d5ba2dbb319f1ce9d63fc2b615597d4bcfddd4a89a09",
|
||||
"https://deno.land/std@0.204.0/path/mod.ts": "2d62a0a8b78a60e8e6f485d881bac6b61d58573b11cf585fb7c8fc50d9b20d80",
|
||||
"https://deno.land/std@0.204.0/path/normalize.ts": "aa95be9a92c7bd4f9dc0ba51e942a1973e2b93d266cd74f5ca751c136d520b66",
|
||||
"https://deno.land/std@0.204.0/path/parse.ts": "d87ff0deef3fb495bc0d862278ff96da5a06acf0625ca27769fc52ac0d3d6ece",
|
||||
"https://deno.land/std@0.204.0/path/posix/_util.ts": "ecf49560fedd7dd376c6156cc5565cad97c1abe9824f4417adebc7acc36c93e5",
|
||||
"https://deno.land/std@0.204.0/path/posix/basename.ts": "a630aeb8fd8e27356b1823b9dedd505e30085015407caa3396332752f6b8406a",
|
||||
"https://deno.land/std@0.204.0/path/posix/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b",
|
||||
"https://deno.land/std@0.204.0/path/posix/dirname.ts": "f48c9c42cc670803b505478b7ef162c7cfa9d8e751b59d278b2ec59470531472",
|
||||
"https://deno.land/std@0.204.0/path/posix/extname.ts": "ee7f6571a9c0a37f9218fbf510c440d1685a7c13082c348d701396cc795e0be0",
|
||||
"https://deno.land/std@0.204.0/path/posix/format.ts": "b94876f77e61bfe1f147d5ccb46a920636cd3cef8be43df330f0052b03875968",
|
||||
"https://deno.land/std@0.204.0/path/posix/from_file_url.ts": "b97287a83e6407ac27bdf3ab621db3fccbf1c27df0a1b1f20e1e1b5acf38a379",
|
||||
"https://deno.land/std@0.204.0/path/posix/glob.ts": "86c3f06d1c98303613c74650961c3e24bdb871cde2a97c3ae7f0f6d4abbef445",
|
||||
"https://deno.land/std@0.204.0/path/posix/is_absolute.ts": "159900a3422d11069d48395568217eb7fc105ceda2683d03d9b7c0f0769e01b8",
|
||||
"https://deno.land/std@0.204.0/path/posix/join.ts": "0c0d84bdc344876930126640011ec1b888e6facf74153ffad9ef26813aa2a076",
|
||||
"https://deno.land/std@0.204.0/path/posix/mod.ts": "6bfa8a42d85345b12dbe8571028ca2c62d460b6ef968125e498602b43b6cf6b6",
|
||||
"https://deno.land/std@0.204.0/path/posix/normalize.ts": "11de90a94ab7148cc46e5a288f7d732aade1d616bc8c862f5560fa18ff987b4b",
|
||||
"https://deno.land/std@0.204.0/path/posix/parse.ts": "199208f373dd93a792e9c585352bfc73a6293411bed6da6d3bc4f4ef90b04c8e",
|
||||
"https://deno.land/std@0.204.0/path/posix/relative.ts": "e2f230608b0f083e6deaa06e063943e5accb3320c28aef8d87528fbb7fe6504c",
|
||||
"https://deno.land/std@0.204.0/path/posix/resolve.ts": "51579d83159d5c719518c9ae50812a63959bbcb7561d79acbdb2c3682236e285",
|
||||
"https://deno.land/std@0.204.0/path/posix/separator.ts": "0b6573b5f3269a3164d8edc9cefc33a02dd51003731c561008c8bb60220ebac1",
|
||||
"https://deno.land/std@0.204.0/path/posix/to_file_url.ts": "08d43ea839ee75e9b8b1538376cfe95911070a655cd312bc9a00f88ef14967b6",
|
||||
"https://deno.land/std@0.204.0/path/posix/to_namespaced_path.ts": "c9228a0e74fd37e76622cd7b142b8416663a9b87db643302fa0926b5a5c83bdc",
|
||||
"https://deno.land/std@0.204.0/path/relative.ts": "23d45ede8b7ac464a8299663a43488aad6b561414e7cbbe4790775590db6349c",
|
||||
"https://deno.land/std@0.204.0/path/resolve.ts": "5b184efc87155a0af9fa305ff68a109e28de9aee81fc3e77cd01380f19daf867",
|
||||
"https://deno.land/std@0.204.0/path/separator.ts": "40a3e9a4ad10bef23bc2cd6c610291b6c502a06237c2c4cd034a15ca78dedc1f",
|
||||
"https://deno.land/std@0.204.0/path/to_file_url.ts": "edaafa089e0bce386e1b2d47afe7c72e379ff93b28a5829a5885e4b6c626d864",
|
||||
"https://deno.land/std@0.204.0/path/to_namespaced_path.ts": "cf8734848aac3c7527d1689d2adf82132b1618eff3cc523a775068847416b22a",
|
||||
"https://deno.land/std@0.204.0/path/windows/_util.ts": "f32b9444554c8863b9b4814025c700492a2b57ff2369d015360970a1b1099d54",
|
||||
"https://deno.land/std@0.204.0/path/windows/basename.ts": "8a9dbf7353d50afbc5b221af36c02a72c2d1b2b5b9f7c65bf6a5a2a0baf88ad3",
|
||||
"https://deno.land/std@0.204.0/path/windows/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b",
|
||||
"https://deno.land/std@0.204.0/path/windows/dirname.ts": "5c2aa541384bf0bd9aca821275d2a8690e8238fa846198ef5c7515ce31a01a94",
|
||||
"https://deno.land/std@0.204.0/path/windows/extname.ts": "07f4fa1b40d06a827446b3e3bcc8d619c5546b079b8ed0c77040bbef716c7614",
|
||||
"https://deno.land/std@0.204.0/path/windows/format.ts": "343019130d78f172a5c49fdc7e64686a7faf41553268961e7b6c92a6d6548edf",
|
||||
"https://deno.land/std@0.204.0/path/windows/from_file_url.ts": "d53335c12b0725893d768be3ac6bf0112cc5b639d2deb0171b35988493b46199",
|
||||
"https://deno.land/std@0.204.0/path/windows/glob.ts": "0286fb89ecd21db5cbf3b6c79e2b87c889b03f1311e66fb769e6b905d4142332",
|
||||
"https://deno.land/std@0.204.0/path/windows/is_absolute.ts": "245b56b5f355ede8664bd7f080c910a97e2169972d23075554ae14d73722c53c",
|
||||
"https://deno.land/std@0.204.0/path/windows/join.ts": "e6600bf88edeeef4e2276e155b8de1d5dec0435fd526ba2dc4d37986b2882f16",
|
||||
"https://deno.land/std@0.204.0/path/windows/mod.ts": "c3d1a36fbf9f6db1320bcb4fbda8de011d25461be3497105e15cbea1e3726198",
|
||||
"https://deno.land/std@0.204.0/path/windows/normalize.ts": "9deebbf40c81ef540b7b945d4ccd7a6a2c5a5992f791e6d3377043031e164e69",
|
||||
"https://deno.land/std@0.204.0/path/windows/parse.ts": "120faf778fe1f22056f33ded069b68e12447668fcfa19540c0129561428d3ae5",
|
||||
"https://deno.land/std@0.204.0/path/windows/relative.ts": "026855cd2c36c8f28f1df3c6fbd8f2449a2aa21f48797a74700c5d872b86d649",
|
||||
"https://deno.land/std@0.204.0/path/windows/resolve.ts": "5ff441ab18a2346abadf778121128ee71bda4d0898513d4639a6ca04edca366b",
|
||||
"https://deno.land/std@0.204.0/path/windows/separator.ts": "ae21f27015f10510ed1ac4a0ba9c4c9c967cbdd9d9e776a3e4967553c397bd5d",
|
||||
"https://deno.land/std@0.204.0/path/windows/to_file_url.ts": "8e9ea9e1ff364aa06fa72999204229952d0a279dbb876b7b838b2b2fea55cce3",
|
||||
"https://deno.land/std@0.204.0/path/windows/to_namespaced_path.ts": "e0f4d4a5e77f28a5708c1a33ff24360f35637ba6d8f103d19661255ef7bfd50d",
|
||||
"https://deno.land/x/async@v2.0.2/barrier.ts": "e5d35a8c565be07b5bd02ea8861745f57cd707fe72ba54eca34948ffe28495d7",
|
||||
"https://deno.land/x/async@v2.0.2/lock.ts": "fd7dc5b54c72d0093072adddb1691e1ae4a801c55b2015589454ea60f1c59adb",
|
||||
"https://deno.land/x/async@v2.0.2/mod.ts": "0671efa8602fe6a6c73caa84d4619b797c1fe0572e31cd78905045ee58c2a6ee",
|
||||
"https://deno.land/x/async@v2.0.2/mutex.ts": "312dcad7468c82f84fd018be157df451361ed19bdc12fd59af8d12b2e6c3ae28",
|
||||
"https://deno.land/x/async@v2.0.2/notify.ts": "3127ab5835c97527fdf978a5ec5844d71a2fd62a75988db6f175aac6f61259d0",
|
||||
"https://deno.land/x/async@v2.0.2/queue.ts": "4df05fbaf376d37379224b68b2b8506a45be835f799139437e7bfc143db5127f",
|
||||
"https://deno.land/x/async@v2.0.2/rw_lock.ts": "ea7a8cf2ee87c21d3fcbb44f2d654f28a06bf71d5ba7a930f04e5619cc4c82fb",
|
||||
"https://deno.land/x/async@v2.0.2/semaphore.ts": "16aca813bc0f9ffed4eb15134271fb677407a6245d1f803294219a607940d636",
|
||||
"https://deno.land/x/async@v2.0.2/stack.ts": "e24ebcbdcab032783e6b278b938475c221fbfb86f8eb71d3752679fcdf132d42",
|
||||
"https://deno.land/x/async@v2.0.2/state.ts": "a71692b72371239120b196d0c6a249631bab867dd499e85c422c2e5ec9695999",
|
||||
"https://deno.land/x/async@v2.0.2/testutil.ts": "c4b4092066ad6f24cf84012781831ff188e656a1e81abf31b0f712d2e1ad07b7",
|
||||
"https://deno.land/x/grammy@v1.19.1/bot.ts": "ff38517817fdc104ed2ef0ab210d5ba1f67675510eabe33b74abd1e586b91316",
|
||||
"https://deno.land/x/grammy@v1.19.1/composer.ts": "8660f86990f4ef2afc4854a1f2610bb8d60f88116f3a57c8e5515a77b277f82d",
|
||||
"https://deno.land/x/grammy@v1.19.1/context.ts": "4cf51ed7538750edb4379f757f6b8b3c1f3987242d58393160b463c9ca13c997",
|
||||
"https://deno.land/x/grammy@v1.19.1/convenience/constants.ts": "3be0f6393ab2b2995fad6bcd4c9cf8a1a615ae4543fc864c107ba0dd38f123f6",
|
||||
"https://deno.land/x/grammy@v1.19.1/convenience/frameworks.ts": "4d4e5ecdcb4f48d3b317c35d8201800c45002e1e195af1b5d7609617f4bdc656",
|
||||
"https://deno.land/x/grammy@v1.19.1/convenience/inline_query.ts": "409d1940c7670708064efa495003bcbfdf6763a756b2e6303c464489fd3394ff",
|
||||
"https://deno.land/x/grammy@v1.19.1/convenience/input_media.ts": "7af72a5fdb1af0417e31b1327003f536ddfdf64e06ab8bc7f5da6b574de38658",
|
||||
"https://deno.land/x/grammy@v1.19.1/convenience/keyboard.ts": "21220dc2321c40203c699fa4eb7b07ed8217956ea0477c241a551224a58a278d",
|
||||
"https://deno.land/x/grammy@v1.19.1/convenience/session.ts": "f92d57b6b2b61920912cf5c44d4db2f6ca999fe4f9adef170c321889d49667c2",
|
||||
"https://deno.land/x/grammy@v1.19.1/convenience/webhook.ts": "f1da7d6426171fb7b5d5f6b59633f91d3bab9a474eea821f714932650965eb9e",
|
||||
"https://deno.land/x/grammy@v1.19.1/core/api.ts": "7d4d8df3567e322ab3b793360ee48da09f46ad531ef994a87b3e6aef4ec23bf2",
|
||||
"https://deno.land/x/grammy@v1.19.1/core/client.ts": "39639e4f5fc3a3f9d528c6906d7e3cdc268cf5d33929eeab801bb39642a59103",
|
||||
"https://deno.land/x/grammy@v1.19.1/core/error.ts": "4638b2127ebe60249c78b83011d468f5e1e1a87748d32fe11a8200d9f824ad13",
|
||||
"https://deno.land/x/grammy@v1.19.1/core/payload.ts": "420e17c3c2830b5576ea187cfce77578fe09f1204b25c25ea2f220ca7c86e73b",
|
||||
"https://deno.land/x/grammy@v1.19.1/filter.ts": "201ddac882ab6cd46cae2d18eb8097460dfe7cedadaab2ba16959c5286d5a5f1",
|
||||
"https://deno.land/x/grammy@v1.19.1/mod.ts": "b81cccf69779667b36bef5d0373d1567684917a3b9827873f3de7a7e6af1926f",
|
||||
"https://deno.land/x/grammy@v1.19.1/platform.deno.ts": "84735643c8dde2cf8af5ac2e6b8eb0768452260878da93238d673cb1b4ccea55",
|
||||
"https://deno.land/x/grammy@v1.19.1/types.deno.ts": "0f47eacde6d3d65f107f2abf16ecfe726298d30263367cc82e977c801b766229",
|
||||
"https://deno.land/x/grammy@v1.19.1/types.ts": "729415590dfa188dbe924dea614dff4e976babdbabb28a307b869fc25777cdf0",
|
||||
"https://deno.land/x/grammy_files@v1.0.4/deps.deno.ts": "ec398579e7a7f69788fc3c3ef90202bc9e031e13bfbd90ee37c88a655b6742ef",
|
||||
"https://deno.land/x/grammy_files@v1.0.4/files.ts": "c69a4cfdcf5b75f32b9de97c36e2b197fe9d60feb360b71e89dff5ae22ba1114",
|
||||
"https://deno.land/x/grammy_files@v1.0.4/mod.ts": "379c5f64594cd879653cdce715c318b3eed29ab26e071f356b9b61f9e3943bc3",
|
||||
"https://deno.land/x/grammy_files@v1.0.4/plugin.ts": "a804b76d4ceaae727b874bb6c88bd7a7b16d086992aae04e23f24e0e4fccd3ab",
|
||||
"https://deno.land/x/grammy_parse_mode@1.8.1/deps.deno.ts": "5297e947875ce8ea523e237bbbfa1eb7c709ed8d76dbeeb8d1a5128182a6753b",
|
||||
"https://deno.land/x/grammy_parse_mode@1.8.1/format.ts": "570be2b4ba5d4122d4c69f6fae3d507f5a2aa4f6177501d8769f3bbc5e6c77ff",
|
||||
"https://deno.land/x/grammy_parse_mode@1.8.1/hydrate.ts": "58e6887ede8319b6f4001bc3e007130c09ba40ca7eaada1387c77a510c9e6b6c",
|
||||
"https://deno.land/x/grammy_parse_mode@1.8.1/mod.ts": "58b539ea91fa72c1fd66dd5415b6c4b63104301b07d9daf860bd66343db7062c",
|
||||
"https://deno.land/x/grammy_parse_mode@1.8.1/transformer.ts": "794d49c27e62b024d3733759aaafab9043fcdacd4fe53a8d7d4703b39f4f049e",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/deps.deno.ts": "bbbaffd1020e0c91acacf02fbc43cdaee04f1d63446cb6a42255d36a0c0e86d4",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/distribute.ts": "318ed928c437ce0a09956abfa40c0b937bb507dc276bc3db5d571e15a4d1ede1",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/mod.ts": "53c08b39527b0b47ffdf4c5996bc3cfbac802ab37074a7b900b2a63d3db273d3",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/platform.deno.ts": "5990d453979176708af79baea54ba2bc4bdc662079f088ce7668b9f4d1962b8c",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/queue.ts": "d715f98096405c558c2657ef245b5acd1b5de916eed39dbad1399db18e57e0f7",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/runner.ts": "2600b9bd39e724d5c629c94e541f497868dfde6cf55be7745dc1d777c8a8b180",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/sequentialize.ts": "137764a109ff3fe4bc8809b6d1d8865ed5c2ad8b649e7553b10302d70bf2e7fb",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/sink.ts": "4c0acb814dee97ffffaea145413b53ccf64c0b00f105b0993383f25111ed8702",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/source.ts": "68ce5aefa92205db4710c8ac313e0c8e8c2577dd1b88f350b32ee33549188d01",
|
||||
"https://deno.land/x/grammy_runner@v2.0.3/worker.ts": "b6f88b9fde15a8dd892c8d4781e8010ef37b80faf04d6ad18db4a43e4faa42ad",
|
||||
"https://deno.land/x/grammy_stateless_question_alpha@v3.0.4/mod.ts": "2c634ee172fa86da8581ccee02eb43a1af5f377837ee78668dd6dbea446038ab",
|
||||
"https://deno.land/x/grammy_stateless_question_alpha@v3.0.4/source/deps.ts": "4b45ad28d0514ed55d7e2cd54d7d6fefa3cbc7680b38b8f6ce01d25137350deb",
|
||||
"https://deno.land/x/grammy_stateless_question_alpha@v3.0.4/source/identifier.ts": "162971fdfa5d48484c4db6ce664d60af27affb5e0bfdab41c065200332b30226",
|
||||
"https://deno.land/x/grammy_stateless_question_alpha@v3.0.4/source/index.ts": "b3272436230d7f19f1e9ee515c52e03bbd20ff6963da9b4076d4d3e6935d969d",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/api.ts": "efc90a31eb6f59ae5e7a4cf5838f46529e2fa6fa7e97a51a82dbd28afad21592",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/inline.ts": "b5669d79f8c0c6f7d6ca856d548c1ac7d490efd54ee785d18a7c4fc12abfd73b",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/manage.ts": "e39ec87e74469f70f35aa51dc520b02136ea5e75f9d7a7e0e513846a00b63fd2",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/markup.ts": "7b547b79130a112f98fbd3f0f754c8bb926f7cab3040d244b5f597aea0e1ce09",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/message.ts": "e78a7797174c537bb8de80597e265121615fa36a531dd88ac5af27aa68779172",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/methods.ts": "7547cedfec2c2727b30b8fa38050aee6642c56673b21cfd0ac56b0e531f02795",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/mod.ts": "7b5f421b4fbb1761f7f0d68328eaddd515f3222ce3f3cdfbedd8d5a4781e91a7",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/passport.ts": "e3fb63aec96510bcc317ef48fd25b435444b8f407502d7568c00fce15f2958fd",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/payment.ts": "d23e9038c5b479b606e620dd84e3e67b6642ada110a962f2d5b5286e99ec7de5",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/settings.ts": "5e989f5bd6c587d55673bd8052293869aa2f372e9223dd7f6e28632bfe021b6e",
|
||||
"https://deno.land/x/grammy_types@v3.3.0/update.ts": "6d5ec6d1f6d2acf021f807f6bbf7d541487f30672cfab4700e7f935a490c3b78",
|
||||
"https://deno.land/x/indexed_kv@v0.5.0/mod.ts": "8a0598817202231dbc0be25149d68c21bca82064303270725764814ee43292ea",
|
||||
"https://deno.land/x/kvfs@v0.1.0/mod.ts": "ceb4c28a6ed850f2fe40bf2349fc8564406811349c7dd6be615db3f098b0790e",
|
||||
"https://deno.land/x/kvmq@v0.3.0/mod.ts": "ce39abbef6a8f0c25a9141dcac82ef165d4ad74a78289eace7bb168bbc645806",
|
||||
"https://deno.land/x/lz4@v0.1.2/mod.ts": "4decfc1a3569d03fd1813bd39128b71c8f082850fe98ecfdde20025772916582",
|
||||
"https://deno.land/x/lz4@v0.1.2/wasm.js": "b9c65605327ba273f0c76a6dc596ec534d4cda0f0225d7a94ebc606782319e46",
|
||||
"https://deno.land/x/reroute@v0.1.0/mod.ts": "2cce7958e13efc2ef1be800dffe6c7043b96d3e514f35dee9cbf60d05d09d2fd",
|
||||
"https://deno.land/x/serve_spa@v0.2.0/mod.ts": "3ddf85357324b2dd75721b3614accfa01af9a87c292a85e2ed17a9387665f400",
|
||||
"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/ulid@v0.3.0/mod.ts": "f7ff065b66abd485051fc68af23becef6ccc7e81f7774d7fcfd894a4b2da1984",
|
||||
"https://esm.sh/@grammyjs/types@2.0.0": "5e5d1ae9f014cb64f0af36fb91f9d35414d670049b77367818a70cd62092b2ac",
|
||||
"https://esm.sh/@grammyjs/types@2.12.1": "eebe448d3bf3d4fdaeacee50e31b9e3947ce965d2591f1036e5c0273cb7aec36",
|
||||
"https://esm.sh/@twind/core@1.1.3?dev": "e774ab3507f745e78dc57dc719a65c356530e248b3ce848fea6a27f84df9619d",
|
||||
"https://esm.sh/@twind/preset-tailwind@1.1.4?dev": "3e2b0d9e56cba8a4a5f85273efede31667a820125b67479ca3be354fa335e2ae",
|
||||
"https://esm.sh/file-type@18.5.0": "f01f6eddb05d365925545e26bd449f934dc042bead882b2795c35c4f48d690a3",
|
||||
"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?dev": "db94655481962a38b32ffbbb0ad50df0a5582ee3e139acb375f989f74b630d82",
|
||||
"https://esm.sh/react-flip-move@3.0.5?dev": "f92489b69efcaba985c9f45b4f4888a24aee7076cf5c03dad378e103d0c1e492",
|
||||
"https://esm.sh/react-intl@6.4.7?external=react&alias=@types/react:react&dev": "7b36a3ea6a607b5d20409129fb43541e665fbcd3607fb8d54541b66a6199b1a2",
|
||||
"https://esm.sh/react-router-dom@6.16.0?dev": "da2bc1a0702667487b3724590e225fd4ea62e8ef8645b06eefbe29b64e6ad161",
|
||||
"https://esm.sh/react@18.2.0?dev": "1fe185734f3d77826efb78a22779899d143bb1160baaf5bbf01edee9f4eaa9d5",
|
||||
"https://esm.sh/stable/react@18.2.0/denonext/react.development.mjs": "88729b33eccbea2c09894f07459f32911777721a3b50fd32151182e82c4351e2",
|
||||
"https://esm.sh/swr@2.2.4?dev": "51d948a1c62a540e833c2ee9c5be77474781a7fcb491b3e2fc6987a64280ffb7",
|
||||
"https://esm.sh/ty-rest@0.4.0/client?dev": "536ada606fc8d34ae6edf7799755ff73a1c7573f945aa0ebece57d032d776ba6",
|
||||
"https://esm.sh/ty-rest@0.4.0/server?dev": "c0db37ac0313d1ceed63791b165dd234335c16e718b23026171db9b5595664ee",
|
||||
"https://esm.sh/use-local-storage@3.0.0?dev": "ddecd8b50fdb196ee3520f2d4f656069bd550833c701af8333ee348923b4ef18",
|
||||
"https://esm.sh/v133/@formatjs/ecma402-abstract@1.17.2/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/ecma402-abstract.development.mjs": "6bc8e991e96ec1a0c91598525756a528a7b4927d570eef2b19caaaccc1fcd8bf",
|
||||
"https://esm.sh/v133/@formatjs/fast-memoize@2.2.0/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/fast-memoize.development.mjs": "4c027b3308490b65dc899f683b1ff9be8da4b7a2e1e32433ef03bcb8f0fdf821",
|
||||
"https://esm.sh/v133/@formatjs/icu-messageformat-parser@2.6.2/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/icu-messageformat-parser.development.mjs": "30783c1741a478baa30ebf9a71ea0e2416a706265e14a229b0992058dfd6687e",
|
||||
"https://esm.sh/v133/@formatjs/icu-skeleton-parser@1.6.2/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/icu-skeleton-parser.development.mjs": "85a61304c66fe8cc15da3899b79df44864c0f1a9dea73d6b23bbf944c973ff64",
|
||||
"https://esm.sh/v133/@formatjs/intl-localematcher@0.4.2/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/intl-localematcher.development.mjs": "26325e3fc4b1728583a863c46332b6878c377bbb918a39896eb0d9f39eb41357",
|
||||
"https://esm.sh/v133/@formatjs/intl@2.9.3/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/intl.development.mjs": "b01ab881505e8d29bf9b337f9b48841893203e063d31775eed6ba292acfac298",
|
||||
"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/@remix-run/router@1.9.0/denonext/router.development.mjs": "97f96f031a7298b0afbbadb23177559ee9b915314d7adf0b9c6a8d7f452c70e7",
|
||||
"https://esm.sh/v133/@twind/core@1.1.3/denonext/core.development.mjs": "1dd38a506c4b728456796e87caa55a31c3855edc6a052a391264c96ee6b89f67",
|
||||
"https://esm.sh/v133/@twind/preset-tailwind@1.1.4/denonext/preset-tailwind.development.mjs": "fc8acfed444337d323d8d6ab5d6198cbc4573a26816387c66ebf6524d20bd772",
|
||||
"https://esm.sh/v133/client-only@0.0.1/denonext/client-only.development.mjs": "b7efaef2653f7f628d084e24a4d5a857c9cd1a5e5060d1d9957e185ee98c8a28",
|
||||
"https://esm.sh/v133/crc-32@0.3.0/denonext/crc-32.mjs": "92bbd96cd5a92e45267cf4b3d3928b9355d16963da1ba740637fb10f1daca590",
|
||||
"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-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/hoist-non-react-statics.development.mjs": "6e66f502f9c4bfeadf9edaa8613965eab0dadab8c22cc83afa4f9d238cdb5153",
|
||||
"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/intl-messageformat@10.5.3/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/intl-messageformat.development.mjs": "8c995acc5e8423a90576dfce8c00b181243987f1ff9922cf6162da4c228e6392",
|
||||
"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/png-chunk-text@1.0.0/denonext/png-chunk-text.mjs": "e8bb89595ceab2603531693319da08a9dd90d51169437de47a73cf8bac7baa11",
|
||||
"https://esm.sh/v133/png-chunks-extract@1.0.0/denonext/png-chunks-extract.mjs": "2a9b62c9478e2bb79f7ccc9725be3b79afa473089ac978d8fd14a8b0cba7c040",
|
||||
"https://esm.sh/v133/react-dom@18.2.0/denonext/client.development.js": "372d345cb97c5ec7b9cd9042d49bdea157c2a231db8078c4857ca9354e1a8da0",
|
||||
"https://esm.sh/v133/react-dom@18.2.0/denonext/react-dom.development.mjs": "a16c087a0cdb5b98c3eaf44c5d24a680e21fc69a8b144859e14ebdd1849755d7",
|
||||
"https://esm.sh/v133/react-flip-move@3.0.5/denonext/react-flip-move.development.mjs": "1a139b6c46af5e4e5302b1c748e80bfd03144989e8c81dea4fbd83dfb6cf4b57",
|
||||
"https://esm.sh/v133/react-intl@6.4.7/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/react-intl.development.mjs": "9017a5dc9316c3f068eb81794f69ac5a6944e649dcec4a45bad2826c0352fd69",
|
||||
"https://esm.sh/v133/react-is@16.13.1/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/react-is.development.mjs": "9e2c3272e256b176f71660a52bf0f8a079babbd01e680de070d47ed51a9319bd",
|
||||
"https://esm.sh/v133/react-router-dom@6.16.0/denonext/react-router-dom.development.mjs": "4c4022a51af6e07f477e454d0bf3a4a085a41ccf21d76b59663533d717d86f66",
|
||||
"https://esm.sh/v133/react-router@6.16.0/denonext/react-router.development.mjs": "c7f1443519d01e5791bc35f64cb561be72e3473060a1e14844b47c50d5e02223",
|
||||
"https://esm.sh/v133/readable-stream@3.6.2/denonext/readable-stream.mjs": "4d368fe1058f90ecfb37581103cae1646123180b8fc4aa77157fd733aed24e4a",
|
||||
"https://esm.sh/v133/readable-web-to-node-stream@3.0.2/denonext/readable-web-to-node-stream.mjs": "8e7f8b7139f71b1cf94cb4d4772239755e996d88bcefb462f03fd6a0b8b4cd83",
|
||||
"https://esm.sh/v133/scheduler@0.23.0/denonext/scheduler.development.mjs": "b6c8f513ecc4afb69b0c5c78a7674bb1c78f7e003946c6c8c655778d81d775db",
|
||||
"https://esm.sh/v133/strtok3@7.0.0/denonext/core.js": "4934052fd9086facbb9436e905dfdee19ee27a43d0b0a8fca39648e833324577",
|
||||
"https://esm.sh/v133/swr@2.2.4/denonext/_internal.development.js": "a9cec421ab0aaa32d9c7e5b55fd73b6d34b4331c71a58e0deab37437c7f263d8",
|
||||
"https://esm.sh/v133/swr@2.2.4/denonext/swr.development.mjs": "edbaabd9f0a72387a39fb09bcac308adde07f8b7a756d4b7c5cdc6aa79ec9a4d",
|
||||
"https://esm.sh/v133/token-types@5.0.1/denonext/token-types.mjs": "eb8ef626bdfc077ae8f9b6c0842e3bdae25b4867dd9d38b7b1b5f003819a06d3",
|
||||
"https://esm.sh/v133/tslib@2.6.2/X-YS9AdHlwZXMvcmVhY3Q6cmVhY3QKZS9yZWFjdA/denonext/tslib.development.mjs": "40dea6e88a1261c5c96a641d73276ffecae3f2d23b9a93d1c5294125f4474cdb",
|
||||
"https://esm.sh/v133/ty-rest@0.4.0/denonext/client.development.js": "00f1de0ffd56afd5da29d786c73c35820f1493c7a81d2c12c006449841228c9b",
|
||||
"https://esm.sh/v133/ty-rest@0.4.0/denonext/server.development.js": "e5cee0108b4abbe0b1a7d336c15aaa5955c313503088ebbbc393a05a031265bf",
|
||||
"https://esm.sh/v133/use-local-storage@3.0.0/denonext/use-local-storage.development.mjs": "72e24a28434c3de0024ba46b62517a74611c6ce0872468f6890b3cbfbfa21a07",
|
||||
"https://esm.sh/v133/use-sync-external-store@1.2.0/denonext/shim.development.js": "6d809e52639ca720eee46a6c63ba55c597eb72d7eae1edd95cf3643538e46158",
|
||||
"https://esm.sh/v133/util-deprecate@1.0.2/denonext/util-deprecate.mjs": "f69f67cf655c38428b0934e0f7c865c055834a87cc3866b629d6b2beb21005e9"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
import React, { useRef } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { AdminData } from "../api/adminsRoute.ts";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
|
||||
export function AdminsPage(props: { sessionId: string | null }) {
|
||||
const { sessionId } = props;
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const addDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const getAdmins = useSWR(
|
||||
["/admins", { method: "GET" }] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{getUser.data && getAdmins.data && getAdmins.data.length === 0 && (
|
||||
<button
|
||||
className="button-filled"
|
||||
onClick={() => {
|
||||
fetchApi("/admins/promote_self", {
|
||||
method: "POST",
|
||||
query: { sessionId: sessionId ?? "" },
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
}}
|
||||
>
|
||||
Promote me to admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
{getAdmins.data?.length
|
||||
? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{getAdmins.data.map((admin) => (
|
||||
<AdminListItem key={admin.tgUserId} admin={admin} sessionId={sessionId} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
: getAdmins.data?.length === 0
|
||||
? (
|
||||
<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
|
||||
? <p className="alert">Loading admins failed</p>
|
||||
: <div className="spinner self-center" />}
|
||||
|
||||
{getUser.data?.admin && (
|
||||
<button
|
||||
className="button-filled"
|
||||
onClick={() => addDialogRef.current?.showModal()}
|
||||
>
|
||||
Add admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AddAdminDialog
|
||||
dialogRef={addDialogRef}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddAdminDialog(props: {
|
||||
dialogRef: React.RefObject<HTMLDialogElement>;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, sessionId } = props;
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="dialog animate-pop-in backdrop-animate-fade-in">
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
fetchApi("/admins", {
|
||||
method: "POST",
|
||||
query: { sessionId: sessionId! },
|
||||
body: {
|
||||
tgUserId: Number(data.get("tgUserId") as string),
|
||||
},
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Telegram user ID
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="tgUserId"
|
||||
required
|
||||
pattern="-?\d+"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" className="button-filled">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminListItem(props: { admin: AdminData; sessionId: string | null }) {
|
||||
const { admin, sessionId } = props;
|
||||
|
||||
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const getAdminUser = useSWR(
|
||||
["/users/:userId", { params: { userId: String(admin.tgUserId) } }] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
||||
<p className="font-bold">
|
||||
{getAdminUser.data?.first_name ?? admin.tgUserId} {getAdminUser.data?.last_name}{" "}
|
||||
{getAdminUser.data?.username
|
||||
? (
|
||||
<a href={`https://t.me/${getAdminUser.data.username}`} className="link">
|
||||
@{getAdminUser.data.username}
|
||||
</a>
|
||||
)
|
||||
: null}
|
||||
</p>
|
||||
{getAdminUser.data?.bio
|
||||
? (
|
||||
<p>
|
||||
{getAdminUser.data?.bio}
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{getUser.data?.admin && (
|
||||
<p className="flex gap-2">
|
||||
<button
|
||||
className="button-outlined"
|
||||
onClick={() => deleteDialogRef.current?.showModal()}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<DeleteAdminDialog
|
||||
dialogRef={deleteDialogRef}
|
||||
adminId={admin.tgUserId}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAdminDialog(props: {
|
||||
dialogRef: React.RefObject<HTMLDialogElement>;
|
||||
adminId: number;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, adminId, sessionId } = props;
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="dialog animate-pop-in backdrop-animate-fade-in"
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
fetchApi("/admins/:adminId", {
|
||||
method: "DELETE",
|
||||
query: { sessionId: sessionId! },
|
||||
params: { adminId: String(adminId) },
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete this admin?
|
||||
</p>
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" className="button-filled">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
20
ui/App.tsx
20
ui/App.tsx
|
@ -1,24 +1,27 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import useLocalStorage from "use-local-storage";
|
||||
import { AdminsPage } from "./AdminsPage.tsx";
|
||||
import { AppHeader } from "./AppHeader.tsx";
|
||||
import { QueuePage } from "./QueuePage.tsx";
|
||||
import { SettingsPage } from "./SettingsPage.tsx";
|
||||
import { StatsPage } from "./StatsPage.tsx";
|
||||
import { WorkersPage } from "./WorkersPage.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
import { useLocalStorage } from "./useLocalStorage.ts";
|
||||
|
||||
export function App() {
|
||||
// store session ID in the local storage
|
||||
const [sessionId, setSessionId] = useLocalStorage("sessionId", "");
|
||||
const [sessionId, setSessionId] = useLocalStorage("sessionId");
|
||||
|
||||
// initialize a new session when there is no session ID
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
fetchApi("sessions", "POST", {}).then(handleResponse).then((session) => {
|
||||
console.log("Initialized session", session.id);
|
||||
setSessionId(session.id);
|
||||
});
|
||||
fetchApi("/sessions", { method: "POST" }).then((resp) => resp).then(handleResponse).then(
|
||||
(session) => {
|
||||
console.log("Initialized session", session.id);
|
||||
setSessionId(session.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
|
@ -27,11 +30,12 @@ export function App() {
|
|||
<AppHeader
|
||||
className="self-stretch"
|
||||
sessionId={sessionId}
|
||||
onLogOut={() => setSessionId("")}
|
||||
onLogOut={() => setSessionId(null)}
|
||||
/>
|
||||
<div className="self-center w-full max-w-screen-md flex flex-col items-stretch gap-4 p-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<StatsPage />} />
|
||||
<Route path="/admins" element={<AdminsPage sessionId={sessionId} />} />
|
||||
<Route path="/workers" element={<WorkersPage sessionId={sessionId} />} />
|
||||
<Route path="/queue" element={<QueuePage />} />
|
||||
<Route path="/settings" element={<SettingsPage sessionId={sessionId} />} />
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { cx } from "@twind/core";
|
||||
import React, { ReactNode } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
import { cx } from "twind/core";
|
||||
import { API_URL, fetchApi, handleResponse } from "./apiClient.ts";
|
||||
|
||||
function NavTab(props: { to: string; children: ReactNode }) {
|
||||
return (
|
||||
|
@ -15,35 +15,50 @@ function NavTab(props: { to: string; children: ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function AppHeader(
|
||||
props: { className?: string; sessionId?: string; onLogOut: () => void },
|
||||
) {
|
||||
export function AppHeader(props: {
|
||||
className?: string;
|
||||
sessionId: string | null;
|
||||
onLogOut: () => void;
|
||||
}) {
|
||||
const { className, sessionId, onLogOut } = props;
|
||||
|
||||
const session = useSWR(
|
||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { method: "GET", params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
{ onError: () => onLogOut() },
|
||||
);
|
||||
|
||||
const user = useSWR(
|
||||
session.data?.userId
|
||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", {
|
||||
method: "GET",
|
||||
params: { userId: String(getSession.data.userId) },
|
||||
}] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const bot = useSWR(
|
||||
['bot',"GET",{}] as const, (args) => fetchApi(...args).then(handleResponse),
|
||||
const getBot = useSWR(
|
||||
["/bot", { method: "GET" }] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const userPhoto = useSWR(
|
||||
session.data?.userId
|
||||
? ["users/{userId}/photo", "GET", {
|
||||
params: { userId: String(session.data.userId) },
|
||||
const getUserPhoto = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId/photo", {
|
||||
method: "GET",
|
||||
params: { userId: String(getSession.data.userId) },
|
||||
}] as const
|
||||
: 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 (
|
||||
|
@ -61,6 +76,9 @@ export function AppHeader(
|
|||
<NavTab to="/">
|
||||
Stats
|
||||
</NavTab>
|
||||
<NavTab to="/admins">
|
||||
Admins
|
||||
</NavTab>
|
||||
<NavTab to="/workers">
|
||||
Workers
|
||||
</NavTab>
|
||||
|
@ -73,29 +91,29 @@ export function AppHeader(
|
|||
</nav>
|
||||
|
||||
{/* loading indicator */}
|
||||
{session.isLoading || user.isLoading ? <div className="spinner" /> : null}
|
||||
{getSession.isLoading || getUser.isLoading ? <div className="spinner" /> : null}
|
||||
|
||||
{/* user avatar */}
|
||||
{user.data
|
||||
? userPhoto.data
|
||||
{getUser.data
|
||||
? getUserPhoto.data
|
||||
? (
|
||||
<img
|
||||
src={userPhoto.data}
|
||||
src={getUserPhoto.data}
|
||||
alt="avatar"
|
||||
className="w-9 h-9 rounded-full"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="w-9 h-9 rounded-full bg-zinc-400 dark:bg-zinc-500 flex items-center justify-center text-white text-2xl font-bold select-none">
|
||||
{user.data.first_name.at(0)?.toUpperCase()}
|
||||
{getUser.data.first_name.at(0)?.toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{/* login/logout button */}
|
||||
{!session.isLoading && !user.isLoading && bot.data && sessionId
|
||||
{!getSession.isLoading && !getUser.isLoading && getBot.data && sessionId
|
||||
? (
|
||||
user.data
|
||||
getUser.data
|
||||
? (
|
||||
<button className="button-outlined" onClick={() => onLogOut()}>
|
||||
Logout
|
||||
|
@ -104,7 +122,7 @@ export function AppHeader(
|
|||
: (
|
||||
<a
|
||||
className="button-filled"
|
||||
href={`https://t.me/${bot.data.username}?start=${sessionId}`}
|
||||
href={`https://t.me/${getBot.data.username}?start=${sessionId}`}
|
||||
target="_blank"
|
||||
>
|
||||
Login
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { cx } from "@twind/core";
|
||||
import React from "react";
|
||||
import { cx } from "twind/core";
|
||||
|
||||
function CounterDigit(props: { value: number; transitionDurationMs?: number }) {
|
||||
function CounterDigit(props: { value: number; transitionDurationMs?: number | undefined }) {
|
||||
const { value, transitionDurationMs = 1500 } = props;
|
||||
const rads = -(Math.floor(value) % 1_000_000) * 2 * Math.PI * 0.1;
|
||||
|
||||
|
@ -36,10 +36,10 @@ const CounterText = (props: { children: React.ReactNode }) => (
|
|||
export function Counter(props: {
|
||||
value: number;
|
||||
digits: number;
|
||||
fractionDigits?: number;
|
||||
transitionDurationMs?: number;
|
||||
className?: string;
|
||||
postfix?: string;
|
||||
fractionDigits?: number | undefined;
|
||||
transitionDurationMs?: number | undefined;
|
||||
className?: string | undefined;
|
||||
postfix?: string | undefined;
|
||||
}) {
|
||||
const { value, digits, fractionDigits = 0, transitionDurationMs, className, postfix } = props;
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { cx } from "@twind/core";
|
||||
import React from "react";
|
||||
import { cx } from "twind/core";
|
||||
|
||||
export function Progress(props: { value: number; className?: string }) {
|
||||
const { value, className } = props;
|
||||
|
|
|
@ -3,11 +3,11 @@ import FlipMove from "react-flip-move";
|
|||
import useSWR from "swr";
|
||||
import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
|
||||
import { Progress } from "./Progress.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
|
||||
export function QueuePage() {
|
||||
const jobs = useSWR(
|
||||
["jobs", "GET", {}] as const,
|
||||
const getJobs = useSWR(
|
||||
["/jobs", {}] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
{ refreshInterval: 2000 },
|
||||
);
|
||||
|
@ -15,36 +15,43 @@ export function QueuePage() {
|
|||
return (
|
||||
<FlipMove
|
||||
typeName={"ul"}
|
||||
className="flex flex-col items-stretch gap-2 p-2 bg-zinc-200 dark:bg-zinc-800 rounded-xl"
|
||||
className="flex flex-col items-stretch gap-2 p-2 bg-zinc-200 dark:bg-zinc-800 rounded-md"
|
||||
enterAnimation="fade"
|
||||
leaveAnimation="fade"
|
||||
>
|
||||
{jobs.data?.map((job) => (
|
||||
<li
|
||||
className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-xl"
|
||||
key={job.id.join("/")}
|
||||
>
|
||||
<span className="">
|
||||
{job.place}.
|
||||
</span>
|
||||
<span>{getFlagEmoji(job.state.from.language_code)}</span>
|
||||
<strong>{job.state.from.first_name} {job.state.from.last_name}</strong>
|
||||
{job.state.from.username
|
||||
? (
|
||||
<a className="link" href={`https://t.me/${job.state.from.username}`} target="_blank">
|
||||
@{job.state.from.username}
|
||||
</a>
|
||||
)
|
||||
: null}
|
||||
<span className="flex-grow self-center h-full">
|
||||
{job.state.progress != null &&
|
||||
<Progress className="w-full h-full" value={job.state.progress} />}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{job.state.workerInstanceKey}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{getJobs.data && getJobs.data.length === 0
|
||||
? <li key="no-jobs" className="text-center text-gray-500">Queue is empty.</li>
|
||||
: (
|
||||
getJobs.data?.map((job) => (
|
||||
<li
|
||||
className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-md"
|
||||
key={job.id}
|
||||
>
|
||||
<span className="">{job.place}.</span>
|
||||
<span>{getFlagEmoji(job.state.from.language_code ?? undefined)}</span>
|
||||
<strong>{job.state.from.first_name} {job.state.from.last_name}</strong>
|
||||
{job.state.from.username
|
||||
? (
|
||||
<a
|
||||
className="link"
|
||||
href={`https://t.me/${job.state.from.username}`}
|
||||
target="_blank"
|
||||
>
|
||||
@{job.state.from.username}
|
||||
</a>
|
||||
)
|
||||
: null}
|
||||
<span className="flex-grow self-center h-full">
|
||||
{job.state.progress != null && (
|
||||
<Progress className="w-full h-full" value={job.state.progress} />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{job.state.workerInstanceKey}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</FlipMove>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,79 +1,84 @@
|
|||
import { cx } from "@twind/core";
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
import { cx } from "twind/core";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
|
||||
export function SettingsPage(props: { sessionId: string }) {
|
||||
export function SettingsPage(props: { sessionId: string | null }) {
|
||||
const { sessionId } = props;
|
||||
const session = useSWR(
|
||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const user = useSWR(
|
||||
session.data?.userId
|
||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String( getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const params = useSWR(
|
||||
["settings/params", "GET", {}] as const,
|
||||
const getParams = useSWR(
|
||||
["/settings/params", {}] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const [changedParams, setChangedParams] = useState<Partial<typeof params.data>>({});
|
||||
const [error, setError] = useState<string>();
|
||||
const [newParams, setNewParams] = useState<Partial<typeof getParams.data>>({});
|
||||
const [patchParamsError, setPatchParamsError] = useState<string>();
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
params.mutate(() =>
|
||||
fetchApi("settings/params", "PATCH", {
|
||||
query: { sessionId },
|
||||
body: { type: "application/json", data: changedParams ?? {} },
|
||||
getParams.mutate(() =>
|
||||
fetchApi("/settings/params", {
|
||||
method: "PATCH",
|
||||
query: { sessionId: sessionId ?? "" },
|
||||
body: omitUndef(newParams ?? {}),
|
||||
}).then(handleResponse)
|
||||
)
|
||||
.then(() => setChangedParams({}))
|
||||
.catch((e) => setError(String(e)));
|
||||
.then(() => setNewParams({}))
|
||||
.catch((e) => setPatchParamsError(String(e)));
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Negative prompt {changedParams?.negative_prompt != null ? "(Changed)" : ""}
|
||||
Negative prompt {newParams?.negative_prompt != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<textarea
|
||||
className="input-text"
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.negative_prompt ??
|
||||
params.data?.negative_prompt ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.negative_prompt ??
|
||||
getParams.data?.negative_prompt ??
|
||||
""}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
negative_prompt: e.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Sampler {changedParams?.sampler_name != null ? "(Changed)" : ""}
|
||||
Sampler {newParams?.sampler_name != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.sampler_name ??
|
||||
params.data?.sampler_name ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.sampler_name ??
|
||||
getParams.data?.sampler_name ??
|
||||
""}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
sampler_name: e.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Steps {changedParams?.steps != null ? "(Changed)" : ""}
|
||||
Steps {newParams?.steps != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<input
|
||||
|
@ -82,12 +87,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
min={5}
|
||||
max={50}
|
||||
step={5}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.steps ??
|
||||
params.data?.steps ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.steps ??
|
||||
getParams.data?.steps ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
steps: Number(e.target.value),
|
||||
}))}
|
||||
|
@ -98,21 +103,22 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
min={5}
|
||||
max={50}
|
||||
step={5}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.steps ??
|
||||
params.data?.steps ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.steps ??
|
||||
getParams.data?.steps ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
steps: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Detail {changedParams?.cfg_scale != null ? "(Changed)" : ""}
|
||||
Detail {newParams?.cfg_scale != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<input
|
||||
|
@ -121,12 +127,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.cfg_scale ??
|
||||
params.data?.cfg_scale ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.cfg_scale ??
|
||||
getParams.data?.cfg_scale ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
cfg_scale: Number(e.target.value),
|
||||
}))}
|
||||
|
@ -137,22 +143,23 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.cfg_scale ??
|
||||
params.data?.cfg_scale ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.cfg_scale ??
|
||||
getParams.data?.cfg_scale ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
cfg_scale: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Width {changedParams?.width != null ? "(Changed)" : ""}
|
||||
Width {newParams?.width != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
|
@ -160,12 +167,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.width ??
|
||||
params.data?.width ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.width ??
|
||||
getParams.data?.width ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
width: Number(e.target.value),
|
||||
}))}
|
||||
|
@ -176,12 +183,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.width ??
|
||||
params.data?.width ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.width ??
|
||||
getParams.data?.width ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
width: Number(e.target.value),
|
||||
}))}
|
||||
|
@ -189,7 +196,7 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Height {changedParams?.height != null ? "(Changed)" : ""}
|
||||
Height {newParams?.height != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
|
@ -197,12 +204,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.height ??
|
||||
params.data?.height ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.height ??
|
||||
getParams.data?.height ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
height: Number(e.target.value),
|
||||
}))}
|
||||
|
@ -213,35 +220,54 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.height ??
|
||||
params.data?.height ??
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.height ??
|
||||
getParams.data?.height ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
height: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error ? <Alert onClose={() => setError(undefined)}>{error}</Alert> : null}
|
||||
{params.error ? <Alert>{params.error.message}</Alert> : null}
|
||||
|
||||
{patchParamsError
|
||||
? (
|
||||
<p className="alert">
|
||||
<span className="flex-grow">Updating params failed: {patchParamsError}</span>
|
||||
<button className="button-ghost" onClick={() => setPatchParamsError(undefined)}>
|
||||
Close
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{getParams.error
|
||||
? (
|
||||
<p className="alert">
|
||||
<span className="flex-grow">
|
||||
Loading params failed: {String(getParams.error)}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className={cx("button-outlined ripple", params.isLoading && "bg-stripes")}
|
||||
disabled={params.isLoading || !user.data?.isAdmin ||
|
||||
Object.keys(changedParams ?? {}).length === 0}
|
||||
onClick={() => setChangedParams({})}
|
||||
className={cx("button-outlined ripple", getParams.isLoading && "bg-stripes")}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin ||
|
||||
Object.keys(newParams ?? {}).length === 0}
|
||||
onClick={() => setNewParams({})}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={cx("button-filled ripple", params.isLoading && "bg-stripes")}
|
||||
disabled={params.isLoading || !user.data?.isAdmin ||
|
||||
Object.keys(changedParams ?? {}).length === 0}
|
||||
className={cx("button-filled ripple", getParams.isLoading && "bg-stripes")}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin ||
|
||||
Object.keys(newParams ?? {}).length === 0}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
@ -249,26 +275,3 @@ export function SettingsPage(props: { sessionId: string }) {
|
|||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Alert(props: { children: ReactNode; onClose?: () => void }) {
|
||||
const { children, onClose } = props;
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="px-4 py-2 flex gap-2 items-center bg-red-500 text-white rounded-sm shadow-md"
|
||||
>
|
||||
<span className="flex-grow">{children}</span>
|
||||
{onClose
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="button-ghost"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React from "react";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
import useSWR from "swr";
|
||||
import { Counter } from "./Counter.tsx";
|
||||
|
||||
export function StatsPage() {
|
||||
const globalStats = useSWR(
|
||||
["stats", "GET", {}] as const,
|
||||
const getGlobalStats = useSWR(
|
||||
["/stats", {}] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
{ refreshInterval: 2_000 },
|
||||
);
|
||||
|
@ -16,13 +16,13 @@ export function StatsPage() {
|
|||
<span>Pixelsteps diffused</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={globalStats.data?.pixelStepCount ?? 0}
|
||||
value={getGlobalStats.data?.pixelStepCount ?? 0}
|
||||
digits={15}
|
||||
transitionDurationMs={3_000}
|
||||
/>
|
||||
<Counter
|
||||
className="text-base"
|
||||
value={(globalStats.data?.pixelStepsPerMinute ?? 0) / 60}
|
||||
value={(getGlobalStats.data?.pixelStepsPerMinute ?? 0) / 60}
|
||||
digits={9}
|
||||
transitionDurationMs={2_000}
|
||||
postfix="/s"
|
||||
|
@ -32,13 +32,13 @@ export function StatsPage() {
|
|||
<span>Pixels painted</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={globalStats.data?.pixelCount ?? 0}
|
||||
value={getGlobalStats.data?.pixelCount ?? 0}
|
||||
digits={15}
|
||||
transitionDurationMs={3_000}
|
||||
/>
|
||||
<Counter
|
||||
className="text-base"
|
||||
value={(globalStats.data?.pixelsPerMinute ?? 0) / 60}
|
||||
value={(getGlobalStats.data?.pixelsPerMinute ?? 0) / 60}
|
||||
digits={9}
|
||||
transitionDurationMs={2_000}
|
||||
postfix="/s"
|
||||
|
@ -49,13 +49,13 @@ export function StatsPage() {
|
|||
<span>Steps processed</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={globalStats.data?.stepCount ?? 0}
|
||||
value={getGlobalStats.data?.stepCount ?? 0}
|
||||
digits={9}
|
||||
transitionDurationMs={3_000}
|
||||
/>
|
||||
<Counter
|
||||
className="text-base"
|
||||
value={(globalStats.data?.stepsPerMinute ?? 0) / 60}
|
||||
value={(getGlobalStats.data?.stepsPerMinute ?? 0) / 60}
|
||||
digits={3}
|
||||
fractionDigits={3}
|
||||
transitionDurationMs={2_000}
|
||||
|
@ -66,13 +66,13 @@ export function StatsPage() {
|
|||
<span>Images generated</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={globalStats.data?.imageCount ?? 0}
|
||||
value={getGlobalStats.data?.imageCount ?? 0}
|
||||
digits={9}
|
||||
transitionDurationMs={3_000}
|
||||
/>
|
||||
<Counter
|
||||
className="text-base"
|
||||
value={(globalStats.data?.imagesPerMinute ?? 0) / 60}
|
||||
value={(getGlobalStats.data?.imagesPerMinute ?? 0) / 60}
|
||||
digits={3}
|
||||
fractionDigits={3}
|
||||
transitionDurationMs={2_000}
|
||||
|
@ -84,7 +84,7 @@ export function StatsPage() {
|
|||
<span>Unique users</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={globalStats.data?.userCount ?? 0}
|
||||
value={getGlobalStats.data?.userCount ?? 0}
|
||||
digits={6}
|
||||
transitionDurationMs={1_500}
|
||||
/>
|
||||
|
|
|
@ -1,182 +1,206 @@
|
|||
import React, { useRef } from "react";
|
||||
import React, { RefObject, useRef } from "react";
|
||||
import { FormattedRelativeTime } from "react-intl";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { WorkerData } from "../api/workersRoute.ts";
|
||||
import { WorkerResponse } from "../api/workersRoute.ts";
|
||||
import { Counter } from "./Counter.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
|
||||
export function WorkersPage(props: { sessionId?: string }) {
|
||||
export function WorkersPage(props: { sessionId: string | null }) {
|
||||
const { sessionId } = props;
|
||||
|
||||
const createWorkerModalRef = useRef<HTMLDialogElement>(null);
|
||||
const addDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const session = useSWR(
|
||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const user = useSWR(
|
||||
session.data?.userId
|
||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const workers = useSWR(
|
||||
["workers", "GET", {}] as const,
|
||||
const getWorkers = useSWR(
|
||||
["/workers", { method: "GET" }] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
{ refreshInterval: 5000 },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="my-4 flex flex-col gap-2">
|
||||
{workers.data?.map((worker) => (
|
||||
<WorkerListItem key={worker.id} worker={worker} sessionId={sessionId} />
|
||||
))}
|
||||
</ul>
|
||||
{user.data?.isAdmin && (
|
||||
{getWorkers.data?.length
|
||||
? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{getWorkers.data?.map((worker) => (
|
||||
<WorkerListItem key={worker.id} worker={worker} sessionId={sessionId} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
: getWorkers.data?.length === 0
|
||||
? (
|
||||
<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
|
||||
? <p className="alert">Loading workers failed</p>
|
||||
: <div className="spinner self-center" />}
|
||||
|
||||
{getUser.data?.admin && (
|
||||
<button
|
||||
className="button-filled"
|
||||
onClick={() => createWorkerModalRef.current?.showModal()}
|
||||
onClick={() => addDialogRef.current?.showModal()}
|
||||
>
|
||||
Add worker
|
||||
</button>
|
||||
)}
|
||||
<dialog
|
||||
className="dialog"
|
||||
ref={createWorkerModalRef}
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const key = data.get("key") as string;
|
||||
const name = data.get("name") as string;
|
||||
const sdUrl = data.get("url") as string;
|
||||
const user = data.get("user") as string;
|
||||
const password = data.get("password") as string;
|
||||
console.log(key, name, user, password);
|
||||
workers.mutate(async () => {
|
||||
const worker = await fetchApi("workers", "POST", {
|
||||
query: { sessionId: sessionId! },
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: {
|
||||
key,
|
||||
name: name || null,
|
||||
sdUrl,
|
||||
sdAuth: user && password ? { user, password } : null,
|
||||
},
|
||||
},
|
||||
}).then(handleResponse);
|
||||
return [...(workers.data ?? []), worker];
|
||||
});
|
||||
|
||||
createWorkerModalRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Key
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="key"
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-zinc-500">
|
||||
Used for counting statistics
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Name
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="name"
|
||||
/>
|
||||
<span className="text-sm text-zinc-500">
|
||||
Used for display
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
URL
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="url"
|
||||
name="url"
|
||||
required
|
||||
pattern="https?://.*"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
User
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="user"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Password
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="password"
|
||||
name="password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => createWorkerModalRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" disabled={!sessionId} className="button-filled">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<AddWorkerDialog
|
||||
dialogRef={addDialogRef}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerListItem(props: { worker: WorkerData; sessionId?: string }) {
|
||||
const { worker, sessionId } = props;
|
||||
const editWorkerModalRef = useRef<HTMLDialogElement>(null);
|
||||
const deleteWorkerModalRef = useRef<HTMLDialogElement>(null);
|
||||
function AddWorkerDialog(props: {
|
||||
dialogRef: RefObject<HTMLDialogElement>;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, sessionId } = props;
|
||||
|
||||
const session = useSWR(
|
||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="dialog animate-pop-in backdrop-animate-fade-in">
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const key = data.get("key") as string;
|
||||
const name = data.get("name") as string;
|
||||
const sdUrl = data.get("url") as string;
|
||||
const user = data.get("user") as string;
|
||||
const password = data.get("password") as string;
|
||||
console.log(key, name, user, password);
|
||||
fetchApi("/workers", {
|
||||
method: "POST",
|
||||
query: { sessionId: sessionId! },
|
||||
body: {
|
||||
key,
|
||||
name: name || null,
|
||||
sdUrl,
|
||||
sdAuth: user && password ? { user, password } : null,
|
||||
},
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Key
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="key"
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-zinc-500">
|
||||
Used for counting statistics
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Name
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="name"
|
||||
/>
|
||||
<span className="text-sm text-zinc-500">
|
||||
Used for display
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
URL
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="url"
|
||||
name="url"
|
||||
required
|
||||
pattern="https?://.*"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
User
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="user"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Password
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="password"
|
||||
name="password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" disabled={!sessionId} className="button-filled">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerListItem(props: {
|
||||
worker: WorkerResponse;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { worker, sessionId } = props;
|
||||
const editDialogRef = useRef<HTMLDialogElement>(null);
|
||||
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const user = useSWR(
|
||||
session.data?.userId
|
||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
||||
<p className="font-bold">
|
||||
|
@ -212,119 +236,151 @@ function WorkerListItem(props: { worker: WorkerData; sessionId?: string }) {
|
|||
{" "}
|
||||
<Counter value={worker.stepsPerMinute} digits={3} /> steps per minute
|
||||
</p>
|
||||
<p className="flex gap-2">
|
||||
{user.data?.isAdmin && (
|
||||
<>
|
||||
<button
|
||||
className="button-outlined"
|
||||
onClick={() => editWorkerModalRef.current?.showModal()}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
className="button-outlined"
|
||||
onClick={() => deleteWorkerModalRef.current?.showModal()}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<dialog
|
||||
className="dialog"
|
||||
ref={editWorkerModalRef}
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const user = data.get("user") as string;
|
||||
const password = data.get("password") as string;
|
||||
console.log(user, password);
|
||||
fetchApi("workers/{workerId}", "PATCH", {
|
||||
params: { workerId: worker.id },
|
||||
query: { sessionId: sessionId! },
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: {
|
||||
auth: user && password ? { user, password } : null,
|
||||
},
|
||||
},
|
||||
});
|
||||
editWorkerModalRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
User
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="user"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Password
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="password"
|
||||
name="password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => editWorkerModalRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" disabled={!sessionId} className="button-filled">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog
|
||||
className="dialog"
|
||||
ref={deleteWorkerModalRef}
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
fetchApi("workers/{workerId}", "DELETE", {
|
||||
params: { workerId: worker.id },
|
||||
query: { sessionId: sessionId! },
|
||||
}).then(handleResponse).then(() => mutate(["workers", "GET", {}]));
|
||||
deleteWorkerModalRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete this worker?
|
||||
</p>
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => deleteWorkerModalRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" disabled={!sessionId} className="button-filled">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
{getUser.data?.admin && (
|
||||
<p className="flex gap-2">
|
||||
<button
|
||||
className="button-outlined"
|
||||
onClick={() => editDialogRef.current?.showModal()}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
className="button-outlined"
|
||||
onClick={() => deleteDialogRef.current?.showModal()}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<EditWorkerDialog
|
||||
dialogRef={editDialogRef}
|
||||
workerId={worker.id}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
<DeleteWorkerDialog
|
||||
dialogRef={deleteDialogRef}
|
||||
workerId={worker.id}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function EditWorkerDialog(props: {
|
||||
dialogRef: RefObject<HTMLDialogElement>;
|
||||
workerId: string;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, workerId, sessionId } = props;
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog
|
||||
className="dialog animate-pop-in backdrop-animate-fade-in"
|
||||
ref={dialogRef}
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const user = data.get("user") as string;
|
||||
const password = data.get("password") as string;
|
||||
console.log(user, password);
|
||||
fetchApi("/workers/:workerId", {
|
||||
method: "PATCH",
|
||||
params: { workerId: workerId },
|
||||
query: { sessionId: sessionId! },
|
||||
body: {
|
||||
sdAuth: user && password ? { user, password } : null,
|
||||
},
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
User
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="user"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Password
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="password"
|
||||
name="password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" disabled={!sessionId} className="button-filled">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteWorkerDialog(props: {
|
||||
dialogRef: RefObject<HTMLDialogElement>;
|
||||
workerId: string;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, workerId, sessionId } = props;
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog
|
||||
className="dialog animate-pop-in backdrop-animate-fade-in"
|
||||
ref={dialogRef}
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
fetchApi("/workers/:workerId", {
|
||||
method: "DELETE",
|
||||
params: { workerId: workerId },
|
||||
query: { sessionId: sessionId! },
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete this worker?
|
||||
</p>
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" disabled={!sessionId} className="button-filled">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { edenFetch } from "elysia/eden";
|
||||
import { Api } from "../api/serveApi.ts";
|
||||
|
||||
export const API_URL = "/api";
|
||||
|
||||
export const fetchApi = edenFetch<Api>(API_URL);
|
||||
|
||||
export function handleResponse<
|
||||
T extends
|
||||
| { data: unknown; error: null }
|
||||
| { data: null; error: { status: number; value: unknown } },
|
||||
>(
|
||||
response: T,
|
||||
): (T & { error: null })["data"] {
|
||||
if (response.error) {
|
||||
throw new Error(`${response.error?.status}: ${response.error?.value}`);
|
||||
}
|
||||
return response.data;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import { createFetcher, Output } from "t_rest/client";
|
||||
import { ApiHandler } from "../api/serveApi.ts";
|
||||
|
||||
export const fetchApi = createFetcher<ApiHandler>({
|
||||
baseUrl: `${location.origin}/api/`,
|
||||
});
|
||||
|
||||
export function handleResponse<T extends Output>(
|
||||
response: T,
|
||||
): (T & { status: 200 })["body"]["data"] {
|
||||
if (response.status !== 200) {
|
||||
throw new Error(String(response.body.data));
|
||||
}
|
||||
return response.body.data;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
34
ui/twind.ts
34
ui/twind.ts
|
@ -1,11 +1,12 @@
|
|||
import { defineConfig, injectGlobal, install } from "@twind/core";
|
||||
import presetTailwind from "@twind/preset-tailwind";
|
||||
import { defineConfig, injectGlobal, install } from "twind/core";
|
||||
import presetTailwind from "twind/preset-tailwind";
|
||||
|
||||
const twConfig = defineConfig({
|
||||
presets: [presetTailwind()],
|
||||
hash: false,
|
||||
});
|
||||
|
||||
install(twConfig);
|
||||
install(twConfig, false);
|
||||
|
||||
injectGlobal`
|
||||
@layer base {
|
||||
|
@ -27,14 +28,11 @@ injectGlobal`
|
|||
rgba(0, 0, 0, 0.1) 14px,
|
||||
rgba(0, 0, 0, 0.1) 28px
|
||||
);
|
||||
animation: bg-stripes-scroll 0.5s linear infinite;
|
||||
animation: bg-scroll 0.5s linear infinite;
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
@keyframes bg-stripes-scroll {
|
||||
0% {
|
||||
background-position: 0 40px;
|
||||
}
|
||||
100% {
|
||||
@keyframes bg-scroll {
|
||||
to {
|
||||
background-position: 40px 0;
|
||||
}
|
||||
}
|
||||
|
@ -63,11 +61,11 @@ injectGlobal`
|
|||
opacity 0s;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
.backdrop-animate-fade-in::backdrop {
|
||||
animation: fade-in 0.3s ease-out forwards;
|
||||
}
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
@ -76,13 +74,13 @@ injectGlobal`
|
|||
animation: pop-in 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
@keyframes pop-in {
|
||||
0% {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer components {
|
||||
.link {
|
||||
@apply text-sky-600 dark:text-sky-500 rounded-sm focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-sky-600 dark:focus:outline-sky-500;
|
||||
|
@ -92,6 +90,10 @@ injectGlobal`
|
|||
@apply h-8 w-8 animate-spin rounded-full border-4 border-transparent border-t-current;
|
||||
}
|
||||
|
||||
.alert {
|
||||
@apply px-4 py-2 flex gap-2 items-center bg-red-500 text-white rounded-sm shadow-md;
|
||||
}
|
||||
|
||||
.button-filled {
|
||||
@apply rounded-md bg-sky-600 px-3 py-2 min-h-12
|
||||
text-sm font-semibold uppercase tracking-wider text-white shadow-sm transition-all
|
||||
|
@ -114,7 +116,7 @@ injectGlobal`
|
|||
}
|
||||
|
||||
.tab {
|
||||
@apply inline-flex items-center px-4 text-sm text-center bg-transparent border-b-2 sm:text-base whitespace-nowrap focus:outline-none
|
||||
@apply inline-flex items-center px-4 text-sm text-center bg-transparent border-b-2 sm:text-base whitespace-nowrap outline-none
|
||||
border-transparent dark:text-white cursor-base hover:border-zinc-400 focus:border-zinc-400 transition-all;
|
||||
}
|
||||
|
||||
|
@ -134,8 +136,8 @@ injectGlobal`
|
|||
}
|
||||
|
||||
.dialog {
|
||||
@apply animate-pop-in backdrop:animate-fade-in overflow-hidden overflow-y-auto rounded-md
|
||||
bg-zinc-100 text-zinc-900 shadow-lg backdrop:bg-black/20 dark:bg-zinc-800 dark:text-zinc-100;
|
||||
@apply overflow-hidden overflow-y-auto rounded-md shadow-xl
|
||||
bg-white text-zinc-900 shadow-lg backdrop:bg-black/30 dark:bg-zinc-800 dark:text-zinc-100;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLocalStorage(key: string) {
|
||||
const [value, setValue] = useState(() => window.localStorage.getItem(key));
|
||||
|
||||
useEffect(() => {
|
||||
console.log(key, value);
|
||||
if (value != null) {
|
||||
window.localStorage.setItem(key, value);
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}, [key, value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === key) {
|
||||
console.log(key, event.newValue);
|
||||
setValue(event.newValue);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,11 @@
|
|||
import { Chat, User } from "grammy_types";
|
||||
|
||||
export function formatUserChat(
|
||||
ctx: { from?: User; chat?: Chat; workerInstanceKey?: string },
|
||||
ctx: {
|
||||
from?: User | undefined;
|
||||
chat?: Chat | undefined;
|
||||
workerInstanceKey?: string | undefined;
|
||||
},
|
||||
) {
|
||||
const msg: string[] = [];
|
||||
if (ctx.from) {
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
export interface KvMemoizeOptions<A extends Deno.KvKey, R> {
|
||||
/**
|
||||
* The time in milliseconds until the cached result expires.
|
||||
*/
|
||||
expireIn?: ((result: R, ...args: A) => number) | number | undefined;
|
||||
/**
|
||||
* Whether to recalculate the result if it was already cached.
|
||||
*
|
||||
* Runs whenever the result is retrieved from the cache.
|
||||
*/
|
||||
shouldRecalculate?: ((result: R, ...args: A) => boolean) | undefined;
|
||||
/**
|
||||
* Whether to cache the result after computing it.
|
||||
*
|
||||
* Runs whenever a new result is computed.
|
||||
*/
|
||||
shouldCache?: ((result: R, ...args: A) => boolean) | undefined;
|
||||
/**
|
||||
* Override the default KV store functions.
|
||||
*/
|
||||
override?: {
|
||||
set: (
|
||||
key: Deno.KvKey,
|
||||
args: A,
|
||||
value: R,
|
||||
options: { expireIn?: number },
|
||||
) => Promise<void>;
|
||||
get: (key: Deno.KvKey, args: A) => Promise<R | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoizes the function result in KV store.
|
||||
*/
|
||||
export function kvMemoize<A extends Deno.KvKey, R>(
|
||||
db: Deno.Kv,
|
||||
key: Deno.KvKey,
|
||||
fn: (...args: A) => Promise<R>,
|
||||
options?: KvMemoizeOptions<A, R>,
|
||||
): (...args: A) => Promise<R> {
|
||||
return async (...args) => {
|
||||
const cachedResult = options?.override?.get
|
||||
? await options.override.get(key, args)
|
||||
: (await db.get<R>([...key, ...args])).value;
|
||||
|
||||
if (cachedResult != null) {
|
||||
if (!options?.shouldRecalculate?.(cachedResult, ...args)) {
|
||||
return cachedResult;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fn(...args);
|
||||
|
||||
const expireIn = typeof options?.expireIn === "function"
|
||||
? options.expireIn(result, ...args)
|
||||
: options?.expireIn;
|
||||
|
||||
if (options?.shouldCache?.(result, ...args) ?? (result != null)) {
|
||||
if (options?.override?.set) {
|
||||
await options.override.set(key, args, result, expireIn != null ? { expireIn } : {});
|
||||
} else {
|
||||
await db.set([...key, ...args], result, expireIn != null ? { expireIn } : {});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Removes all undefined properties from an object.
|
||||
*/
|
||||
export function omitUndef<O extends object | undefined>(object: O):
|
||||
& { [K in keyof O as undefined extends O[K] ? never : K]: O[K] }
|
||||
& { [K in keyof O as undefined extends O[K] ? K : never]?: O[K] & ({} | null) } {
|
||||
if (object == undefined) return object as never;
|
||||
return Object.fromEntries(
|
||||
Object.entries(object).filter(([, v]) => v !== undefined),
|
||||
) as never;
|
||||
}
|
Loading…
Reference in New Issue