feat: Support using proxies

This commit is contained in:
jarvis2f 2024-12-22 20:00:13 +08:00
parent 8086e8a485
commit d4ddc70639
17 changed files with 882 additions and 191 deletions

View file

@ -127,6 +127,8 @@ public class HttpVerticle extends AbstractVerticle {
router.get("/telegram/:telegramId/chat/:chatId/files/count").handler(this::handleTelegramFilesCount);
router.get("/telegram/:telegramId/download-statistics").handler(this::handleTelegramDownloadStatistics);
router.post("/telegrams/change").handler(this::handleTelegramChange);
router.post("/telegram/:telegramId/toggle-proxy").handler(this::handleTelegramToggleProxy);
router.get("/telegram/:telegramId/ping").handler(this::handleTelegramPing);
router.get("/file/preview").handler(this::handleFilePreview);
router.post("/file/start-download").handler(this::handleFileStartDownload);
@ -273,9 +275,12 @@ public class HttpVerticle extends AbstractVerticle {
);
return;
}
JsonObject jsonObject = ctx.body().asJsonObject();
String proxyName = jsonObject.getString("proxyName");
TelegramVerticle newTelegramVerticle = new TelegramVerticle(DataVerticle.telegramRepository.getRootPath());
newTelegramVerticle.bindHttpSession(sessionId);
newTelegramVerticle.setProxy(proxyName);
sessionTelegramVerticles.put(sessionId, newTelegramVerticle);
telegramVerticles.add(newTelegramVerticle);
vertx.deployVerticle(newTelegramVerticle)
@ -401,6 +406,29 @@ public class HttpVerticle extends AbstractVerticle {
}, () -> ctx.fail(404));
}
private void handleTelegramToggleProxy(RoutingContext ctx) {
String telegramId = ctx.request().getParam("telegramId");
getTelegramVerticle(telegramId)
.ifPresentOrElse(telegramVerticle ->
telegramVerticle.toggleProxy(ctx.body().asJsonObject())
.onSuccess(r -> ctx.json(JsonObject.of("proxy", r)))
.onFailure(ctx::fail), () -> ctx.fail(404));
}
private void handleTelegramPing(RoutingContext ctx) {
String telegramId = ctx.pathParam("telegramId");
if (StrUtil.isBlank(telegramId)) {
ctx.fail(400);
return;
}
getTelegramVerticle(telegramId)
.ifPresentOrElse(telegramVerticle ->
telegramVerticle.ping()
.onSuccess(r -> ctx.json(JsonObject.of("ping", r)))
.onFailure(ctx::fail), () -> ctx.fail(404)
);
}
private void handleTelegramApi(RoutingContext ctx) {
String method = ctx.pathParam("method");
if (method == null) {

View file

@ -12,10 +12,7 @@ import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.TypeUtil;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.CompositeFuture;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.*;
import io.vertx.core.impl.NoStackTraceException;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonArray;
@ -24,10 +21,7 @@ import org.drinkless.tdlib.Client;
import org.drinkless.tdlib.TdApi;
import org.jooq.lambda.tuple.Tuple;
import org.jooq.lambda.tuple.Tuple2;
import telegram.files.repository.FileRecord;
import telegram.files.repository.SettingAutoRecords;
import telegram.files.repository.SettingKey;
import telegram.files.repository.TelegramRecord;
import telegram.files.repository.*;
import java.io.File;
import java.io.IOError;
@ -50,6 +44,8 @@ public class TelegramVerticle extends AbstractVerticle {
public String rootPath;
private String proxyName;
private String rootId;
private boolean needDelete = false;
@ -74,6 +70,7 @@ public class TelegramVerticle extends AbstractVerticle {
public TelegramVerticle(TelegramRecord telegramRecord) {
this.telegramRecord = telegramRecord;
this.rootPath = telegramRecord.rootPath();
this.proxyName = telegramRecord.proxy();
}
public String getRootId() {
@ -87,14 +84,21 @@ public class TelegramVerticle extends AbstractVerticle {
return telegramRecord == null ? this.getRootId() : telegramRecord.id();
}
public void setProxy(String proxyName) {
this.proxyName = proxyName;
}
@Override
public void start() {
public void start(Promise<Void> startPromise) {
TelegramUpdateHandler telegramUpdateHandler = new TelegramUpdateHandler();
telegramUpdateHandler.setOnAuthorizationStateUpdated(this::onAuthorizationStateUpdated);
telegramUpdateHandler.setOnFileUpdated(this::onFileUpdated);
telegramUpdateHandler.setOnFileDownloadsUpdated(this::onFileDownloadsUpdated);
telegramUpdateHandler.setOnMessageReceived(this::onMessageReceived);
client = Client.create(telegramUpdateHandler, this::handleException, this::handleException);
this.enableProxy(this.proxyName)
.onSuccess(r -> startPromise.complete())
.onFailure(startPromise::fail);
}
public void delete() {
@ -129,7 +133,8 @@ public class TelegramVerticle extends AbstractVerticle {
.put("status", "inactive")
.put("rootPath", this.rootPath)
.put("isPremium", false)
.put("lastAuthorizationState", lastAuthorizationState);
.put("lastAuthorizationState", lastAuthorizationState)
.put("proxy", this.proxyName);
if (this.telegramRecord != null) {
jsonObject.put("id", Convert.toStr(this.telegramRecord.id()))
.put("name", this.telegramRecord.firstName());
@ -146,7 +151,8 @@ public class TelegramVerticle extends AbstractVerticle {
.put("avatar", Base64.encode((byte[]) BeanUtil.getProperty(user, "profilePhoto.minithumbnail.data")))
.put("status", "active")
.put("rootPath", this.rootPath)
.put("isPremium", user.isPremium);
.put("isPremium", user.isPremium)
.put("proxy", this.proxyName);
promise.complete(result);
})
.onFailure(e -> {
@ -158,6 +164,9 @@ public class TelegramVerticle extends AbstractVerticle {
public Future<JsonArray> getChats(Long activatedChatId, String query) {
Consumer<TdApi.Chats> insertActivatedChatId = chats -> {
if (chats == null || ArrayUtil.isEmpty(chats.chatIds)) {
return;
}
long[] chatIds = chats.chatIds;
if (activatedChatId != null && !ArrayUtil.contains(chatIds, activatedChatId)) {
chatIds = (long[]) ArrayUtil.insert(chatIds, 0, activatedChatId);
@ -293,7 +302,9 @@ public class TelegramVerticle extends AbstractVerticle {
if (fileRecord != null) {
promise.complete();
} else {
DataVerticle.fileRepository.create(TdApiHelp.getFileHandler(message).get().convertFileRecord(telegramRecord.id()))
DataVerticle.fileRepository.create(TdApiHelp.getFileHandler(message)
.orElseThrow()
.convertFileRecord(telegramRecord.id()))
.onSuccess(r -> promise.complete())
.onFailure(promise::fail);
}
@ -402,6 +413,87 @@ public class TelegramVerticle extends AbstractVerticle {
return DataVerticle.fileRepository.getDownloadStatistics(this.telegramRecord.id());
}
public Future<TdApi.Proxy> enableProxy(String proxyName) {
if (StrUtil.isBlank(proxyName)) return Future.succeededFuture();
return DataVerticle.settingRepository.<SettingProxyRecords>getByKey(SettingKey.proxys)
.map(settingProxyRecords -> Optional.ofNullable(settingProxyRecords)
.flatMap(r -> r.getProxy(proxyName))
.orElseThrow(() -> new NoStackTraceException("Proxy %s not found".formatted(proxyName)))
)
.compose(proxy -> this.getTdProxy(proxy)
.map(r -> Tuple.tuple(proxy, r))
)
.compose(tuple -> {
SettingProxyRecords.Item proxy = tuple.v1;
TdApi.Proxy tdProxy = tuple.v2;
boolean edit = false;
if (tdProxy != null) {
if (tdProxy.isEnabled) {
return Future.succeededFuture(tdProxy);
}
edit = true;
}
TdApi.ProxyType proxyType;
if (Objects.equals(proxy.type, "http")) {
proxyType = new TdApi.ProxyTypeHttp(proxy.username, proxy.password, false);
} else if (Objects.equals(proxy.type, "socks5")) {
proxyType = new TdApi.ProxyTypeSocks5(proxy.username, proxy.password);
} else {
return Future.failedFuture("Unsupported proxy type: %s".formatted(proxy.type));
}
return edit ? this.execute(new TdApi.EditProxy(tdProxy.id, proxy.server, proxy.port, true, proxyType))
: this.execute(new TdApi.AddProxy(proxy.server, proxy.port, true, proxyType));
})
.compose(r -> DataVerticle.telegramRepository.update(this.telegramRecord.withProxy(proxyName)).map(r))
.compose(r -> {
this.proxyName = proxyName;
return Future.succeededFuture(r);
});
}
public Future<TdApi.Proxy> toggleProxy(JsonObject jsonObject) {
String toggleProxyName = jsonObject.getString("proxyName");
if (Objects.equals(toggleProxyName, this.proxyName)) {
return Future.succeededFuture();
}
if (StrUtil.isBlank(toggleProxyName) && StrUtil.isNotBlank(this.proxyName)) {
// disable proxy
return this.execute(new TdApi.DisableProxy())
.compose(r -> DataVerticle.telegramRepository.update(this.telegramRecord.withProxy(null)))
.andThen(r -> {
this.proxyName = null;
this.telegramRecord = r.result();
})
.mapEmpty();
} else {
return this.enableProxy(toggleProxyName);
}
}
public Future<TdApi.Proxy> getTdProxy(SettingProxyRecords.Item proxy) {
return this.execute(new TdApi.GetProxies())
.map(proxies -> Stream.of(proxies.proxies)
.filter(proxy::equalsTdProxy)
.findFirst()
.orElse(null));
}
public Future<TdApi.Proxy> getTdProxy() {
return this.execute(new TdApi.GetProxies())
.map(proxies -> Stream.of(proxies.proxies)
.filter(p -> p.isEnabled)
.findFirst()
.orElse(null));
}
public Future<Double> ping() {
return this.getTdProxy()
.compose(proxy -> this.execute(new TdApi.PingProxy(proxy == null ? 0 : proxy.id)))
.map(r -> r.seconds);
}
@SuppressWarnings("unchecked")
public <R extends TdApi.Object> Future<R> execute(TdApi.Function<R> method) {
log.trace("[%s] Execute method: %s".formatted(getRootId(), TypeUtil.getTypeArgument(method.getClass())));
@ -506,7 +598,7 @@ public class TelegramVerticle extends AbstractVerticle {
if (telegramRecord == null) {
this.execute(new TdApi.GetMe())
.compose(user ->
DataVerticle.telegramRepository.create(new TelegramRecord(user.id, user.firstName, this.rootPath))
DataVerticle.telegramRepository.create(new TelegramRecord(user.id, user.firstName, this.rootPath, this.proxyName))
)
.compose(record -> {
telegramRecord = record;

View file

@ -15,6 +15,7 @@ public enum SettingKey {
* Auto download limit for each telegram account
*/
autoDownloadLimit(Convert::toInt),
proxys(value -> new JsonObject(value).mapTo(SettingProxyRecords.class)),
;
public final Function<String, ?> converter;

View file

@ -0,0 +1,48 @@
package telegram.files.repository;
import org.drinkless.tdlib.TdApi;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public class SettingProxyRecords {
public List<Item> items;
public static class Item {
/**
* should be unique
*/
public String name;
public String server;
public int port;
public String username;
public String password;
/**
* http, socks5
*/
public String type;
public boolean equalsTdProxy(TdApi.Proxy proxy) {
return Objects.equals(name, proxy.server)
&& port == proxy.port
&& (Objects.equals(type, "http") ? proxy.type instanceof TdApi.ProxyTypeHttp : proxy.type instanceof TdApi.ProxyTypeSocks5);
}
}
public SettingProxyRecords() {
this.items = new ArrayList<>();
}
public Optional<Item> getProxy(String name) {
return items.stream().filter(item -> Objects.equals(name, item.name)).findFirst();
}
}

View file

@ -4,28 +4,38 @@ import cn.hutool.core.map.MapUtil;
import io.vertx.sqlclient.templates.RowMapper;
import io.vertx.sqlclient.templates.TupleMapper;
import java.util.Map;
public record TelegramRecord(long id, String firstName, String rootPath) {
public record TelegramRecord(
long id,
String firstName,
String rootPath,
String proxy
) {
public static final String SCHEME = """
CREATE TABLE IF NOT EXISTS telegram_record
(
id BIGINT PRIMARY KEY,
first_name VARCHAR(255),
root_path VARCHAR(255)
root_path VARCHAR(255),
proxy VARCHAR(255)
)
""";
public static RowMapper<TelegramRecord> ROW_MAPPER = row ->
new TelegramRecord(row.getLong("id"),
row.getString("first_name"),
row.getString("root_path")
row.getString("root_path"),
row.getString("proxy")
);
public static TupleMapper<TelegramRecord> PARAM_MAPPER = TupleMapper.mapper(r ->
Map.ofEntries(MapUtil.entry("id", r.id),
Map.entry("first_name", r.firstName()),
Map.entry("root_path", r.rootPath())
MapUtil.ofEntries(MapUtil.entry("id", r.id),
MapUtil.entry("first_name", r.firstName()),
MapUtil.entry("root_path", r.rootPath()),
MapUtil.entry("proxy", r.proxy())
));
public TelegramRecord withProxy(String proxy) {
return new TelegramRecord(id, firstName, rootPath, proxy);
}
}

View file

@ -12,6 +12,8 @@ public interface TelegramRepository {
Future<TelegramRecord> create(TelegramRecord telegramRecord);
Future<TelegramRecord> update(TelegramRecord telegramRecord);
Future<TelegramRecord> getById(long id);
Future<List<TelegramRecord>> getAll();

View file

@ -47,7 +47,7 @@ public class TelegramRepositoryImpl implements TelegramRepository {
@Override
public Future<TelegramRecord> create(TelegramRecord telegramRecord) {
return SqlTemplate
.forUpdate(pool, "INSERT INTO telegram_record(id, first_name, root_path) VALUES (#{id}, #{first_name}, #{root_path})")
.forUpdate(pool, "INSERT INTO telegram_record(id, first_name, root_path, proxy) VALUES (#{id}, #{first_name}, #{root_path}, #{proxy})")
.mapFrom(TelegramRecord.PARAM_MAPPER)
.execute(telegramRecord)
.map(r -> telegramRecord)
@ -57,6 +57,19 @@ public class TelegramRepositoryImpl implements TelegramRepository {
);
}
@Override
public Future<TelegramRecord> update(TelegramRecord telegramRecord) {
return SqlTemplate
.forUpdate(pool, "UPDATE telegram_record SET first_name = #{first_name}, root_path = #{root_path}, proxy = #{proxy} WHERE id = #{id}")
.mapFrom(TelegramRecord.PARAM_MAPPER)
.execute(telegramRecord)
.map(r -> telegramRecord)
.onSuccess(r -> log.debug("Successfully updated telegram record: %s".formatted(telegramRecord.id())))
.onFailure(
err -> log.error("Failed to update telegram record: %s".formatted(err.getMessage()))
);
}
@Override
public Future<TelegramRecord> getById(long id) {
return SqlTemplate

View file

@ -52,6 +52,10 @@ Cookie: {{tf}}
POST http://{{server}}/telegram/create
Cookie: {{tf}}
### Ping
GET http://{{server}}/telegram/{{telegramId}}/ping
Cookie: {{tf}}
### Get chats
GET http://{{server}}/telegram/{{telegramId}}/chats
Cookie: {{tf}}

View file

@ -20,13 +20,14 @@ import {
} from "@/lib/websocket-types";
import { useToast } from "@/hooks/use-toast";
import useSWRMutation from "swr/mutation";
import { POST, telegramApi, type TelegramApiArg } from "@/lib/api";
import { request, telegramApi, type TelegramApiArg } from "@/lib/api";
import { useTelegramAccount } from "@/hooks/use-telegram-account";
import { cn } from "@/lib/utils";
import { type TelegramApiResult } from "@/lib/types";
import { useSWRConfig } from "swr";
import { useDebounce } from "use-debounce";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import ProxysDialog from "@/components/proxys-dialog";
export function AccountDialog({
children,
@ -37,12 +38,24 @@ export function AccountDialog({
}) {
const { toast } = useToast();
const { mutate } = useSWRConfig();
const [proxyName, setProxyName] = useState<string | undefined>();
const {
data: createData,
trigger: triggerCreate,
isMutating: isCreateMutating,
error: createError,
} = useSWRMutation<{ id: string }, Error>("/telegram/create", POST);
} = useSWRMutation<{ id: string }, Error>(
"/telegram/create",
async (key: string) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await request(key, {
method: "POST",
body: JSON.stringify({
proxyName: proxyName,
}),
});
},
);
const { trigger: triggerMethod, isMutating: isMethodMutating } =
useSWRMutation<TelegramApiResult, Error, string, TelegramApiArg>(
"/telegram/api",
@ -137,7 +150,6 @@ export function AccountDialog({
if (isMethodMutating) return true;
const lastCode = methodCodes[methodCodes.length - 1];
if (!lastCode) return false;
console.log(lastCode, methodCompleteCodes);
return !methodCompleteCodes.includes(lastCode);
}, [isMethodMutating, methodCodes, methodCompleteCodes]);
@ -173,7 +185,7 @@ export function AccountDialog({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
aria-describedby={undefined}
className="h-full w-full md:h-auto md:min-h-40 md:min-w-[550px]"
className="relative h-full w-full pb-10 md:h-auto md:min-h-40 md:min-w-[550px]"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@ -184,7 +196,7 @@ export function AccountDialog({
</DialogTitle>
</DialogHeader>
{!debounceIsCreateMutating && !initSuccessfully && (
<div className="flex items-center justify-center space-x-2">
<div className="flex flex-col items-center justify-center space-y-4">
<Button
className={cn(
"w-full",
@ -291,6 +303,13 @@ export function AccountDialog({
)}
</form>
)}
<ProxysDialog
telegramId={createData?.id}
proxyName={proxyName}
onProxyNameChange={setProxyName}
enableSelect={true}
className="absolute bottom-1 right-1"
/>
</DialogContent>
</Dialog>
);

View file

@ -6,6 +6,7 @@ import { AccountDialog } from "@/components/account-dialog";
import React from "react";
import { Button } from "@/components/ui/button";
import { BorderBeam } from "@/components/ui/border-beam";
import ProxysDialog from "@/components/proxys-dialog";
interface EmptyStateProps {
hasAccounts: boolean;
@ -55,15 +56,19 @@ export function EmptyState({
</p>
</>
)}
<AccountDialog isAdd={true}>
<div className="relative rounded-md">
<BorderBeam size={60} duration={12} delay={9} />
<Button variant="outline">
<UserPlus className="mr-2 h-4 w-4" />
Add Account
</Button>
</div>
</AccountDialog>
<div className="flex items-center justify-center space-x-4">
<AccountDialog isAdd={true}>
<div className="relative rounded-md">
<BorderBeam size={60} duration={12} delay={9} />
<Button variant="outline">
<UserPlus className="mr-2 h-4 w-4" />
Add Account
</Button>
</div>
</AccountDialog>
<ProxysDialog />
</div>
</div>
{hasAccounts && accounts.length > 0 && onSelectAccount && (

View file

@ -0,0 +1,49 @@
import useSWR from "swr";
import { ChevronsLeftRightEllipsis } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function ProxyPing({ accountId }: { accountId: string }) {
const { data, isLoading, error, mutate } = useSWR<
{
ping: number;
},
Error
>(`/telegram/${accountId}/ping`, {
errorRetryCount: 2,
});
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div
className="flex items-center space-x-2 rounded bg-gray-100 p-1"
onClick={() => {
if (isLoading) return;
void mutate(undefined, true);
}}
>
<ChevronsLeftRightEllipsis className="h-4 w-4 text-gray-500" />
{isLoading && (
<div className="h-6 w-24 animate-pulse rounded bg-gray-200"></div>
)}
{!isLoading && error && <span>Connection error</span>}
{!isLoading && data && (
<div className="flex items-center space-x-2">
<span>Ping: {(data.ping * 1000).toFixed(0)}ms</span>
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>
Current latency for accessing telegram API
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -0,0 +1,48 @@
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { EarthLock } from "lucide-react";
import Proxys, { type ProxysProps } from "@/components/proxys";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import React from "react";
import { cn } from "@/lib/utils";
type ProxysDialogProps = {
className?: string;
} & ProxysProps;
export default function ProxysDialog({
className,
...proxyProps
}: ProxysDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
className={cn(
"h-auto rounded-full bg-primary px-2 text-accent hover:bg-primary/90 hover:text-accent",
className,
)}
>
<EarthLock className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent
className="md:max-w-2/3 h-full w-full max-w-full md:h-3/4 md:w-2/3"
aria-describedby={undefined}
>
<VisuallyHidden>
<DialogTitle>Proxys</DialogTitle>
</VisuallyHidden>
<div className="mt-3">
<Proxys {...proxyProps} />
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,337 @@
"use client";
import React, { useState } from "react";
import { useSettings } from "@/hooks/use-settings";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Edit2, Plus, Trash2 } from "lucide-react";
import { type Proxy } from "@/lib/types";
import { BorderBeam } from "@/components/ui/border-beam";
import { cn } from "@/lib/utils";
import useSWRMutation from "swr/mutation";
import { request } from "@/lib/api";
import { toast } from "@/hooks/use-toast";
import ProxyPing from "@/components/proxy-ping";
export interface ProxysProps {
enableSelect?: boolean;
telegramId?: string;
proxyName?: string;
onProxyNameChange?: (name: string) => void;
}
export default function Proxys({
enableSelect,
telegramId,
proxyName,
onProxyNameChange,
}: ProxysProps) {
const { settings, updateSettings } = useSettings();
const [innerProxyName, setInnerProxyName] = useState("");
const [isDialogOpen, setDialogOpen] = useState(false);
const [editingProxy, setEditingProxy] = useState<Proxy | null>(null);
const [formState, setFormState] = useState<Proxy>({
name: "",
server: "",
port: 0,
username: "",
password: "",
type: "http",
});
const { trigger: triggerProxy, isMutating: isToggleProxyMutating } =
useSWRMutation<{ id: string }, Error>(
telegramId ? `/telegram/${telegramId}/toggle-proxy` : undefined,
async (key: string) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await request(key, {
method: "POST",
body: JSON.stringify({
proxyName: innerProxyName,
}),
});
},
{
onSuccess: () => {
toast({
title: "Success",
description: innerProxyName
? `Proxy is set to ${innerProxyName}`
: "Proxy is disabled",
});
},
},
);
// Parse proxy settings
const proxys = (
(settings?.proxys
? JSON.parse(settings.proxys)
: {
items: [],
}) as {
items: Proxy[];
}
).items;
// Open dialog for adding or editing proxy
const handleOpenDialog = (proxy: Proxy | null = null): void => {
setEditingProxy(proxy);
setFormState(
proxy ?? {
name: "",
server: "",
port: 0,
username: "",
password: "",
type: "http",
},
);
setDialogOpen(true);
};
// Close dialog
const handleCloseDialog = (): void => {
setDialogOpen(false);
setEditingProxy(null);
};
// Delete proxy with confirmation
const handleDeleteProxy = (proxy: Proxy): void => {
if (confirm(`Are you sure you want to delete the proxy ${proxy.name}?`)) {
const updatedProxys = proxys.filter((p) => p.name !== proxy.name);
void updateSettings({
proxys: JSON.stringify({
items: updatedProxys,
}),
});
}
};
// Save proxy (either add or edit)
const handleSaveProxy = (): void => {
const updatedProxys = editingProxy
? proxys.map((p) => (p.name === editingProxy.name ? formState : p))
: [...proxys, formState];
void updateSettings({ proxys: JSON.stringify({ items: updatedProxys }) }); // Submit to server
handleCloseDialog();
};
// Handle form input change
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
): void => {
const { name, value } = e.target;
setFormState((prev) => ({
...prev,
[name]: name === "port" ? parseInt(value, 10) : value,
}));
};
const handleProxySubmit = async () => {
if (telegramId) {
await triggerProxy();
} else {
onProxyNameChange?.(innerProxyName);
}
};
return (
<div className="relative h-full">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<h1 className="text-2xl font-bold">Proxys</h1>
{telegramId && <ProxyPing accountId={telegramId} />}
</div>
<Button onClick={() => handleOpenDialog()}>
<Plus className="mr-2 h-5 w-5" /> Add Proxy
</Button>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{proxys.map((proxy) => (
<Card
key={proxy.name}
className={cn("relative hover:shadow-lg", {
"cursor-pointer": enableSelect,
"border border-primary":
enableSelect && proxy.name === innerProxyName,
})}
onClick={() => {
if (innerProxyName === proxy.name) {
setInnerProxyName("");
} else {
setInnerProxyName(proxy.name);
}
}}
>
{proxyName && proxy.name === proxyName && (
<BorderBeam size={100} duration={12} delay={9} />
)}
<CardHeader className="p-2 px-3">
<CardTitle className="text-xl font-semibold">
{proxy.name}
</CardTitle>
</CardHeader>
<CardContent className="p-0 px-3">
<div className="flex items-baseline space-x-2">
<p className="text-lg text-gray-700">
{proxy.type.toUpperCase()}
</p>
<p className="text-sm text-gray-400">{`${proxy.server}:${proxy.port}`}</p>
</div>
</CardContent>
<CardFooter className="flex justify-end space-x-2 p-1">
<Button
size="xs"
variant="ghost"
onClick={() => handleOpenDialog(proxy)}
>
<Edit2 className="h-3 w-3" />
</Button>
<Button
size="xs"
variant="ghost"
onClick={() => handleDeleteProxy(proxy)}
>
<Trash2 className="h-3 w-3 text-red-600" />
</Button>
</CardFooter>
</Card>
))}
</div>
{enableSelect && (
<Button
className="absolute bottom-4 right-4"
disabled={isToggleProxyMutating}
onClick={() => handleProxySubmit()}
>
Submit
</Button>
)}
<Dialog open={isDialogOpen} onOpenChange={handleCloseDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingProxy ? "Edit Proxy" : "Add Proxy"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Radio buttons for proxy type */}
<div>
<label className="mb-1 block text-lg text-gray-700">Type</label>
<div className="flex space-x-4">
<label className="flex items-center space-x-2">
<input
type="radio"
name="type"
value="http"
checked={formState.type === "http"}
onChange={handleInputChange}
/>
<span>HTTP</span>
</label>
<label className="flex items-center space-x-2">
<input
type="radio"
name="type"
value="socks5"
checked={formState.type === "socks5"}
onChange={handleInputChange}
/>
<span>SOCKS5</span>
</label>
</div>
</div>
{/* Other input fields */}
<div>
<label className="mb-1 block text-lg text-gray-700">Name</label>
<Input
name="name"
value={formState.name}
onChange={handleInputChange}
placeholder="Enter proxy name"
/>
</div>
<div>
<p className="mb-1 text-lg text-gray-700">
Proxy server address and port number
</p>
<div className="flex space-x-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-500">
Server
</label>
<Input
name="server"
value={formState.server}
onChange={handleInputChange}
placeholder="Enter server address"
/>
</div>
<div className="w-24">
<label className="block text-sm font-medium text-gray-500">
Port
</label>
<Input
name="port"
type="number"
value={formState.port}
onChange={handleInputChange}
placeholder="Enter port number"
/>
</div>
</div>
</div>
<p className="mb-1 text-lg text-gray-700">
Authentication (optional)
</p>
<div>
<label className="block text-sm font-medium text-gray-500">
Username
</label>
<Input
name="username"
value={formState.username}
onChange={handleInputChange}
placeholder="Enter username"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">
Password
</label>
<Input
name="password"
type="password"
value={formState.password}
onChange={handleInputChange}
placeholder="Enter password"
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseDialog} variant="ghost">
Cancel
</Button>
<Button onClick={handleSaveProxy}>
{editingProxy ? "Save" : "Add"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,4 +1,4 @@
import React, { type FormEvent, useState } from "react";
import React, { useState } from "react";
import {
Dialog,
DialogContent,
@ -6,45 +6,17 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Bell, Settings } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Settings } from "lucide-react";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSettings } from "@/hooks/use-settings";
import { Input } from "@/components/ui/input";
import FileStatistics from "@/components/file-statistics";
import { useTelegramAccount } from "@/hooks/use-telegram-account";
import Proxys from "@/components/proxys";
import SettingsForm from "@/components/settings-form";
export const SettingsDialog: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const { accountId } = useTelegramAccount();
const { settings, setSetting, updateSettings } = useSettings();
const imageLoadSizeOptions = [
{ value: "s", label: "box 100x100" },
{ value: "m", label: "box 320x320" },
{ value: "x", label: "box 800x800" },
{ value: "y", label: "box 1280x1280" },
{ value: "w", label: "box 2560x2560" },
{ value: "a", label: "crop 160x160" },
{ value: "b", label: "crop 320x320" },
{ value: "c", label: "crop 640x640" },
{ value: "d", label: "crop 1280x1280" },
];
const handleSave = async (e: FormEvent) => {
e.preventDefault();
await updateSettings();
};
const { account, accountId } = useTelegramAccount();
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@ -73,128 +45,34 @@ export const SettingsDialog: React.FC = () => {
<TabsList className="justify-start">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="statistics">Statistics</TabsTrigger>
<TabsTrigger value="proxys">Proxys</TabsTrigger>
</TabsList>
<TabsContent
value="general"
className="flex flex-col overflow-hidden"
>
<form onSubmit={handleSave} className="flex flex-col space-y-4 overflow-y-scroll">
<div className="flex flex-col space-y-4 overflow-y-scroll">
<p className="rounded-md bg-gray-50 p-2 text-sm text-muted-foreground shadow">
<Bell className="mr-2 inline-block h-4 w-4" />
These settings will be applied to all accounts.
</p>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<div className="flex items-center space-x-2">
<Label htmlFor="unique-only">Unique Only</Label>
<Checkbox
id="unique-only"
checked={settings?.uniqueOnly === "true"}
onCheckedChange={(checked) =>
void setSetting("uniqueOnly", String(checked))
}
/>
</div>
<p className="text-xs text-muted-foreground">
Show only unique file in the table. If disabled, will show
all. <br />
<strong>Warning:</strong> If enabled, the number of
documents on the form will be inaccurate.
</p>
</div>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<div className="flex items-center space-x-2">
<Label htmlFor="need-load-preview-images">
Need Load Preview Images
</Label>
<Checkbox
id="need-load-preview-images"
checked={settings?.needToLoadImages === "true"}
onCheckedChange={(checked) => {
void setSetting("needToLoadImages", String(checked));
}}
/>
</div>
{settings?.needToLoadImages === "true" && (
<div className="space-y-2">
<Label htmlFor="image-load-size">Load Size</Label>
<Select
value={settings.imageLoadSize}
onValueChange={(v) =>
void setSetting("imageLoadSize", v)
}
>
<SelectTrigger id="image-load-size">
<SelectValue placeholder="Select Image Load Size" />
</SelectTrigger>
<SelectContent>
{imageLoadSizeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The size of the image to load in the browser.
</p>
</div>
)}
</div>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<div className="flex items-center space-x-2">
<Label htmlFor="show-sensitive-content">
Show Sensitive Content
</Label>
<Checkbox
id="show-sensitive-content"
checked={settings?.showSensitiveContent === "true"}
onCheckedChange={(checked) =>
void setSetting("showSensitiveContent", String(checked))
}
/>
</div>
<p className="text-xs text-muted-foreground">
Show sensitive content in the table, Will use a spoiler to
hide sensitive content if disabled.
</p>
</div>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<Label>Auto Download Settings</Label>
<div className="flex flex-col space-y-2">
<Label htmlFor="limit">Limit Per Account</Label>
<Input
id="limit"
className="w-24"
type="number"
min={1}
max={10}
value={settings?.autoDownloadLimit ?? 5}
onChange={(e) => {
void setSetting("autoDownloadLimit", e.target.value);
}}
/>
</div>
</div>
</div>
<div className="mt-2 flex flex-1 justify-end">
<Button type="submit">Submit</Button>
</div>
</form>
<TabsContent value="general" className="h-full">
<div className="flex flex-col overflow-hidden">
<SettingsForm />
</div>
</TabsContent>
<TabsContent
value="statistics"
className="flex flex-col overflow-y-scroll"
>
{accountId ? (
<FileStatistics telegramId={accountId} />
) : (
<div className="flex flex-1 items-center justify-center">
<p className="text-lg text-muted-foreground">
Please select an account to view statistics
</p>
</div>
)}
<TabsContent value="statistics" className="h-full">
<div className="flex flex-col overflow-y-scroll">
{accountId ? (
<FileStatistics telegramId={accountId} />
) : (
<div className="flex flex-1 items-center justify-center">
<p className="text-lg text-muted-foreground">
Please select an account to view statistics
</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="proxys" className="h-full">
<div className="flex h-full flex-col overflow-y-scroll">
<Proxys
telegramId={accountId}
proxyName={account?.proxy}
enableSelect={true}
/>
</div>
</TabsContent>
</Tabs>
</DialogContent>

View file

@ -0,0 +1,142 @@
import { Bell } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import React, { type FormEvent } from "react";
import { useSettings } from "@/hooks/use-settings";
export default function SettingsForm() {
const { settings, setSetting, updateSettings } = useSettings();
const imageLoadSizeOptions = [
{ value: "s", label: "box 100x100" },
{ value: "m", label: "box 320x320" },
{ value: "x", label: "box 800x800" },
{ value: "y", label: "box 1280x1280" },
{ value: "w", label: "box 2560x2560" },
{ value: "a", label: "crop 160x160" },
{ value: "b", label: "crop 320x320" },
{ value: "c", label: "crop 640x640" },
{ value: "d", label: "crop 1280x1280" },
];
const handleSave = async (e: FormEvent) => {
e.preventDefault();
await updateSettings();
};
return (
<form
onSubmit={handleSave}
className="flex h-full flex-col"
>
<div className="flex flex-1 flex-col space-y-4 overflow-y-scroll">
<p className="rounded-md bg-gray-50 p-2 text-sm text-muted-foreground shadow">
<Bell className="mr-2 inline-block h-4 w-4" />
These settings will be applied to all accounts.
</p>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<div className="flex items-center space-x-2">
<Label htmlFor="unique-only">Unique Only</Label>
<Checkbox
id="unique-only"
checked={settings?.uniqueOnly === "true"}
onCheckedChange={(checked) =>
void setSetting("uniqueOnly", String(checked))
}
/>
</div>
<p className="text-xs text-muted-foreground">
Show only unique file in the table. If disabled, will show all.{" "}
<br />
<strong>Warning:</strong> If enabled, the number of documents on the
form will be inaccurate.
</p>
</div>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<div className="flex items-center space-x-2">
<Label htmlFor="need-load-preview-images">
Need Load Preview Images
</Label>
<Checkbox
id="need-load-preview-images"
checked={settings?.needToLoadImages === "true"}
onCheckedChange={(checked) => {
void setSetting("needToLoadImages", String(checked));
}}
/>
</div>
{settings?.needToLoadImages === "true" && (
<div className="space-y-2">
<Label htmlFor="image-load-size">Load Size</Label>
<Select
value={settings.imageLoadSize}
onValueChange={(v) => void setSetting("imageLoadSize", v)}
>
<SelectTrigger id="image-load-size">
<SelectValue placeholder="Select Image Load Size" />
</SelectTrigger>
<SelectContent>
{imageLoadSizeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The size of the image to load in the browser.
</p>
</div>
)}
</div>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<div className="flex items-center space-x-2">
<Label htmlFor="show-sensitive-content">
Show Sensitive Content
</Label>
<Checkbox
id="show-sensitive-content"
checked={settings?.showSensitiveContent === "true"}
onCheckedChange={(checked) =>
void setSetting("showSensitiveContent", String(checked))
}
/>
</div>
<p className="text-xs text-muted-foreground">
Show sensitive content in the table, Will use a spoiler to hide
sensitive content if disabled.
</p>
</div>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<Label>Auto Download Settings</Label>
<div className="flex flex-col space-y-2">
<Label htmlFor="limit">Limit Per Account</Label>
<Input
id="limit"
className="w-24"
type="number"
min={1}
max={10}
value={settings?.autoDownloadLimit ?? 5}
onChange={(e) => {
void setSetting("autoDownloadLimit", e.target.value);
}}
/>
</div>
</div>
</div>
<div className="mt-2 flex justify-end">
<Button type="submit">Submit</Button>
</div>
</form>
);
}

View file

@ -9,7 +9,7 @@ interface SettingsContextType {
isLoading: boolean;
settings?: Settings;
setSetting: (key: SettingKey, value: string) => Promise<void>;
updateSettings: (updates?: Settings) => Promise<void>;
updateSettings: (updates?: Partial<Settings>) => Promise<void>;
}
const SettingsContext = createContext<SettingsContextType | undefined>(
@ -41,13 +41,15 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({
);
// 更新配置项方法
const updateSettings = async (updates?: Settings) => {
const updateSettings = async (updates?: Partial<Settings>) => {
await request("/settings/create", {
body: updates ? JSON.stringify(updates) : JSON.stringify(settings),
method: "POST",
});
// 更新缓存
if (updates) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
await mutate((current) => ({ ...current, ...updates }), false);
}
toast({ title: "Success", description: "Settings updated" });

View file

@ -7,6 +7,7 @@ export type TelegramAccount = {
avatar?: string;
status: "active" | "inactive";
lastAuthorizationState?: TelegramObject;
proxy?: string;
};
export type TelegramChat = {
@ -85,8 +86,20 @@ export const SettingKeys = [
"imageLoadSize",
"showSensitiveContent",
"autoDownloadLimit",
"proxys",
] as const;
export type SettingKey = (typeof SettingKeys)[number];
export type Settings = Record<SettingKey, string>;
export type Proxy = {
id?: string;
name: string;
server: string;
port: number;
username: string;
password: string;
type: "http" | "socks5";
isEnabled?: boolean;
}