Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions migrations/20250910151011_add_trial_fields/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
Warnings:

- You are about to drop the column `daily_limit_mb` on the `users` table. All the data in the column will be lost.

*/
-- AlterTable
ALTER TABLE "public"."users" DROP COLUMN "daily_limit_mb",
ADD COLUMN "expired_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "is_trial" BOOLEAN NOT NULL DEFAULT false;
2 changes: 2 additions & 0 deletions migrations/20250910151124_add_trial_fields/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."users" ALTER COLUMN "expired_at" SET DEFAULT (now() + '3 days'::interval);
15 changes: 0 additions & 15 deletions prisma/schema.prisma

This file was deleted.

3 changes: 2 additions & 1 deletion schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ model User {
username String @unique
password String
env String @default("tg")
dailyLimitMb Int @default(1024) @map("daily_limit_mb")
isDeleted Boolean @default(false) @map("is_deleted")
createdAt DateTime @default(now()) @map("created_at")
modifiedAt DateTime @updatedAt @map("modified_at")
expiredAt DateTime @default(dbgenerated("(now() + '3 days'::interval)")) @map("expired_at")
isTrial Boolean @default(false) @map("is_trial")

@@index([telegramId])
@@index([username])
Expand Down
41 changes: 21 additions & 20 deletions src/bot.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import * as dotenv from "dotenv";
import express from "express";
import { Telegraf, TelegramError } from "telegraf";
import { awaitingMnemonic, siteHandler, statHandler } from "./handlers";
import { handleInboundCallback } from "./handlers/callback/inbound";
import { handleProtoCallback } from "./handlers/callback/proto";
import {
awaitingMnemonic,
siteHandler,
statHandler,
subPlainHandler,
subClashHandler,
} from "./handlers";
import { handleSubscriptionCallback } from "./handlers/callback/sub";
import { connectHandler } from "./handlers/connect";
import { handleSubscriptionClashCallback } from "./handlers/callback/clash";
import { deleteHandler } from "./handlers/delete";
import {
feedbackHandler,
Expand All @@ -14,7 +18,6 @@ import {
} from "./handlers/feedback";
import { scoreHandler } from "./handlers/score";
import { startHandler } from "./handlers/start";
import { subHandler } from "./handlers/sub";
import { BotState } from "./state";
import { connectWithMnemonic } from "./site";

Expand All @@ -31,7 +34,7 @@ const missingVars = requiredEnvVars.filter((varName) => !process.env[varName]);

if (missingVars.length > 0) {
throw new Error(
`Missing required environment variables: ${missingVars.join(", ")}`
`Missing required environment variables: ${missingVars.join(", ")}`,
);
}

Expand All @@ -51,7 +54,7 @@ const botState = new BotState(
API_AUTH_TOKEN,
GOOGLE_SCRIPT_URL,
TOKEN,
ADMIN_CHAT_ID
ADMIN_CHAT_ID,
);

const app = express();
Expand Down Expand Up @@ -111,8 +114,8 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
(async () => {
// Command handlers
bot.command("start", (ctx) => startHandler(ctx, botState));
bot.command("connect", (ctx) => connectHandler(ctx, botState));
bot.command("sub", (ctx) => subHandler(ctx, botState));
bot.command("connect", (ctx) => subPlainHandler(ctx, botState));
bot.command("clash", (ctx) => subClashHandler(ctx, botState));
bot.command("stat", (ctx) => statHandler(ctx, botState));
bot.command("site", (ctx) => siteHandler(ctx, botState));
bot.command("delete", (ctx) => deleteHandler(ctx, botState));
Expand All @@ -139,7 +142,7 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
const words = message.trim().split(/\s+/);
if (words.length !== 12) {
return ctx.reply(
"Фраза должна содержать 12 слов. Попробуйте ещё раз командой /site"
"Фраза должна содержать 12 слов. Попробуйте ещё раз командой /site",
);
}

Expand All @@ -150,7 +153,7 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
} else {
await ctx.reply(
`Ваша ссылка: <code>${data.subscription_url}</code>`,
{ parse_mode: "HTML" }
{ parse_mode: "HTML" },
);
}
} catch (err) {
Expand All @@ -166,13 +169,13 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
await ctx.reply(
"Неизвестная команда 🫣\n\nДоступные команды:\n" +
"• /start - Начать работу\n" +
"• /connect - Подключиться к VPN\n" +
"• /sub - Управление подпиской\n" +
"• /connect - Получить VPN ссылку\n" +
"• /clash - Получить VPN Clash ссылку\n" +
"• /status - Проверить статус\n" +
"• /stat - Статистика\n" +
"• /site - Если оплачивали подписку на сайте\n" +
"• /support - Поддержка и обратная связь\n" +
"• /delete - Удалить аккаунт"
"• /delete - Удалить аккаунт",
);
}
});
Expand Down Expand Up @@ -206,12 +209,10 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
// Route callback data to appropriate handlers
if (data.startsWith("feedback_")) {
await handleFeedbackCallback(ctx, botState);
} else if (data.startsWith("proto_")) {
await handleProtoCallback(ctx, botState);
} else if (data.startsWith("inbound_")) {
await handleInboundCallback(ctx, botState);
} else if (data.startsWith("sub_")) {
} else if (data.startsWith("plain")) {
await handleSubscriptionCallback(ctx, botState);
} else if (data.startsWith("clash")) {
await handleSubscriptionClashCallback(ctx, botState);
} else {
console.log(`[CALLBACK] Unknown callback data: ${data}`);
await ctx.reply("Неизвестная команда.");
Expand Down Expand Up @@ -250,7 +251,7 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
` Domain: ${DOMAIN}\n` +
` Port: ${PORT}\n` +
` Webhook path: ${WEBHOOK_PATH}\n` +
` Environment: ${process.env.NODE_ENV || "development"}`
` Environment: ${process.env.NODE_ENV || "development"}`,
);
})().catch((err) => {
console.error("[STARTUP] Fatal error during startup:", err);
Expand Down
48 changes: 48 additions & 0 deletions src/handlers/callback/clash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import QRCode from "qrcode";
import { Context } from "telegraf";
import { BotState } from "../../state";

export async function handleSubscriptionClashCallback(
ctx: Context,
botState: BotState,
) {
const callbackQuery = ctx.callbackQuery;

if (!callbackQuery || !("data" in callbackQuery)) {
return await ctx.answerCbQuery();
}

const data = callbackQuery.data;
if (!data) {
return await ctx.answerCbQuery();
}

const user = ctx.from;
if (!user || !user.username) {
return ctx.answerCbQuery("Не удалось определить пользователя.");
}

const userEntry = await botState.findUserByTelegramId(user.id);
if (!userEntry || userEntry.is_deleted) {
return ctx.answerCbQuery("Для начала используйте /start");
}

const subLink = botState.getSubLink(userEntry.id, "clash");

const qrBuffer = await QRCode.toBuffer(subLink, {
errorCorrectionLevel: "H",
type: "png",
margin: 8,
scale: 6,
});

await ctx.editMessageText(
`*Нажмите на ссылку, чтобы скопировать* \n\`\`\`\n${subLink}\n\`\`\``,
{ parse_mode: "MarkdownV2" },
);

await ctx.replyWithPhoto(
{ source: qrBuffer },
{ caption: `QR-код для VPN Clash подписки 🧷` },
);
}
Loading