Compare commits

..

13 Commits

Author SHA1 Message Date
0a844f7369 Deploy: First ever deploy
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2025-01-17 13:20:41 +03:00
926774424e Feat: added notifier
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-01-17 13:19:13 +03:00
0d1dd4362d Feat: fixed notifications 2025-01-16 16:51:26 +03:00
7d50cf95cf Feat: started working on the sub notification 2025-01-13 15:50:45 +03:00
b5a893e5aa Feat: some fixes 2025-01-13 15:18:48 +03:00
9ee8e43fb4 Feat: fixed start command 2025-01-13 11:49:59 +03:00
af837b54be Feat: disabled menu command 2025-01-05 21:02:00 +03:00
9f98d9502e Feat: update user command 2025-01-05 20:58:25 +03:00
3962c57dde Feat: add user command 2025-01-05 20:44:51 +03:00
34308f2cac Feat: started main user interface 2025-01-02 22:01:29 +03:00
7093f7452d Feat: added user middleware 2025-01-02 22:01:05 +03:00
e73bc6569b Feat: removed unnecessary bot 2025-01-02 22:00:44 +03:00
5d6310e5a4 Feat: started menu command and admin field 2024-12-28 11:50:40 +03:00
22 changed files with 375 additions and 29 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
__pycache__

View File

@@ -22,7 +22,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres} POSTGRES_DB: ${POSTGRES_DB:-postgres}
ports: ports:
- 5432 - "127.0.0.1:${POSTGRES_PORT}:5432"
healthcheck: healthcheck:
test: ["CMD", "pg_isready"] test: ["CMD", "pg_isready"]
interval: 10s interval: 10s

10
main.py
View File

@@ -5,7 +5,7 @@ from sys import exit
import uvloop import uvloop
from nwxraybot import NwXrayBot, Settings from nwxraybot import NwXrayBot, Settings
from nwxraybot.handlers import HelloHandler from nwxraybot.handlers import *
from nwxraybot.models import User from nwxraybot.models import User
if __name__ == "__main__": if __name__ == "__main__":
@@ -19,7 +19,11 @@ if __name__ == "__main__":
User.create_table() User.create_table()
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
loop = uvloop.new_event_loop()
asyncio.set_event_loop(loop)
# Start bot # Start bot
bot = NwXrayBot(config.bot_token.get_secret_value()) bot = NwXrayBot(config.bot_token.get_secret_value())
bot.include_routers(HelloHandler(bot)()) bot.include_routers(HelloHandler(), MenuHandler(), AdminHandler(bot.bot))
uvloop.run(bot.start(skip_updates=True)) loop.run_until_complete(bot.start(skip_updates=True))

View File

@@ -1,2 +1,3 @@
from nwxraybot.bot import NwXrayBot from nwxraybot.bot import NwXrayBot
from nwxraybot.config import Settings from nwxraybot.config import Settings
from nwxraybot.utils import get_code, get_subscription_info

View File

@@ -1,14 +1,23 @@
from aiogram import Bot, Dispatcher, Router from aiogram import Bot, Dispatcher, Router
from aiogram.utils.callback_answer import CallbackAnswerMiddleware
from nwxraybot.middlewares import UserMiddleware
from nwxraybot.notifiers import setup_subscription_notifier
from .meta import Handler
class NwXrayBot: class NwXrayBot:
def __init__(self, token: str) -> None: def __init__(self, token: str) -> None:
self.bot = Bot(token=token) self.bot = Bot(token=token)
self.dp = Dispatcher() self.dp = Dispatcher()
self.dp.message.middleware(UserMiddleware())
self.dp.message.middleware(CallbackAnswerMiddleware())
setup_subscription_notifier(self.bot)
def include_routers(self, *routers: Router): def include_routers(self, *routers: Handler):
for router in routers: for router in routers:
self.dp.include_router(router) self.dp.include_router(router())
async def start(self, skip_updates: bool = False) -> None: async def start(self, skip_updates: bool = False) -> None:
await self.dp.start_polling(self.bot, skip_updates=skip_updates) await self.dp.start_polling(self.bot, skip_updates=skip_updates)

View File

@@ -0,0 +1 @@
from nwxraybot.fsm.broadcast import BroadcastStates

View File

@@ -0,0 +1,6 @@
from aiogram.fsm.state import State, StatesGroup
class BroadcastStates(StatesGroup):
waiting_for_message = State()
confirming_message = State()

View File

@@ -1 +1,3 @@
from nwxraybot.handlers.admin import AdminHandler
from nwxraybot.handlers.hello import HelloHandler from nwxraybot.handlers.hello import HelloHandler
from nwxraybot.handlers.menu import MenuHandler

103
nwxraybot/handlers/admin.py Normal file
View File

@@ -0,0 +1,103 @@
import asyncio
import logging
import re
from datetime import datetime
from typing import Optional
from aiogram import Bot, F
from aiogram.enums import ParseMode
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import (CallbackQuery, InlineKeyboardButton,
InlineKeyboardMarkup, Message)
from nwxraybot import get_code
from nwxraybot.fsm import BroadcastStates
from nwxraybot.meta import Handler
from nwxraybot.middlewares import AdminMiddleware
from nwxraybot.models import User
class AdminHandler(Handler):
def __init__(self, bot: Optional[Bot] = None) -> None:
super().__init__(bot)
self.router.message.middleware(AdminMiddleware())
@self.router.message(Command('adduser'))
async def add_user(message: Message):
mask = r"^(?P<name>[a-zA-Z0-9]+)\s(?P<url>vless://[^\s]+)($|\s(?P<date>[0-9]{2}\.[0-9]{2}\.[0-9]{4})\s(?P<time>[0-9]{2}\:[0-9]{2})$)"
text = message.text.replace('/adduser ', '')
match = re.match(mask, text)
if match is None:
await message.reply('Вы ввели команду в неверном формате. Вводите в формате:\n``` /adduser name vless://.... 01.01.1970 00:00```', parse_mode=ParseMode.MARKDOWN)
return
user_dict = match.groupdict()
date = None
if user_dict['date']:
date = datetime.strptime(f"{user_dict['date']} {
user_dict['time']}", "%d.%m.%Y %H:%M")
code = get_code()
new_user = User(
name=user_dict['name'], url=user_dict['url'], time=date, code=code)
new_user.save()
await message.answer(f'Пользователь создан. Вот его ссылка для доступа:\n`https://t.me/nwproxybot?start={code}`', parse_mode=ParseMode.MARKDOWN)
@self.router.message(Command('updateuser'))
async def update_user(message: Message):
mask = r"^(?P<name>[a-zA-Z0-9]+)\s(?P<date>[0-9]{2}\.[0-9]{2}\.[0-9]{4})\s(?P<time>[0-9]{2}\:[0-9]{2})$"
text = message.text.replace('/updateuser ', '')
match = re.match(mask, text)
if match is None:
await message.reply('Вы ввели команду в неверном формате. Вводите в формате:\n``` /updateuser name 01.01.1970 00:00```', parse_mode=ParseMode.MARKDOWN)
return
user_dict = match.groupdict()
date = datetime.strptime(f"{user_dict['date']} {
user_dict['time']}", "%d.%m.%Y %H:%M")
query = User.update(time=date).where(
User.name == user_dict['name'])
query.execute()
await message.answer('Информация о пользователе обновлена.')
@self.router.message(Command('broadcast'))
async def start_broadcast(message: Message, state: FSMContext):
await message.answer('Отправьте для рассылки')
await state.set_state(BroadcastStates.waiting_for_message)
@self.router.message(BroadcastStates.waiting_for_message)
async def broadcast_message(message: Message, state: FSMContext):
if message.media_group_id is not None:
await state.clear()
await message.answer('Пожалуйста, не отправляйте сообщение с несколькими изображениями.')
return
await state.set_state(BroadcastStates.confirming_message)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="Подтвердить", callback_data='confirm_broadcast')],
[InlineKeyboardButton(
text="Отменить", callback_data='cancel_broadcast')]
])
await state.update_data(message_id=message.message_id)
await message.answer("Подтвердите отправку сообщения", reply_markup=keyboard)
@self.router.callback_query(F.data == 'confirm_broadcast', BroadcastStates.confirming_message)
async def confirm_broadcast(callback: CallbackQuery, state: FSMContext):
await callback.answer()
data = await state.get_data()
message_id = data.get('message_id')
users = User.select().where((User.telegram_id != None))
semaphore = asyncio.Semaphore(10)
for user in users:
async with semaphore:
try:
await self.bot.copy_message(user.telegram_id, callback.from_user.id, message_id)
except Exception as e:
logging.error(f"Error while broadcasting message to user {
user.telegram_id}: {e}")
await state.clear()
@self.router.callback_query(F.data == 'cancel_broadcast', BroadcastStates.confirming_message)
async def cancel_broadcast(callback: CallbackQuery, state: FSMContext):
await callback.answer()
await state.clear()
await callback.message.answer('Рассылка отменена.')

View File

@@ -1,13 +1,59 @@
from aiogram import types from typing import Optional
from aiogram.filters import Command
from aiogram import F, types
from aiogram.enums import ParseMode
from aiogram.filters import Command
from aiogram.types import (CallbackQuery, InlineKeyboardButton,
InlineKeyboardMarkup)
from nwxraybot import get_subscription_info
from nwxraybot.meta import Handler from nwxraybot.meta import Handler
from nwxraybot.models import User
class HelloHandler(Handler): class HelloHandler(Handler):
def __init__(self, bot) -> None: def __non_admin_main_menu(self):
super().__init__(bot) markup = [[
InlineKeyboardButton(text="Обновить данные🔄",
callback_data='update')
]]
return InlineKeyboardMarkup(
inline_keyboard=markup
)
def __init__(self) -> None:
super().__init__()
@self.router.message(Command("start")) @self.router.message(Command("start"))
async def hello(message: types.Message): async def hello(message: types.Message):
await message.reply("Приветствуем в боте NwXray! Здесь вы сможете получить информацию о своем подключении к NwXray") data = message.text.split()
user: Optional[User] = None
if len(data) == 2:
code = data[1]
query = User.update(telegram_id=None).where(
User.telegram_id == message.from_user.id)
query.execute()
user = User.select().where(
User.code == code).first()
if user is None:
await message.answer('Пользователь не найден, обратитесь к администратору за ссылкой!')
return
user.telegram_id = message.from_user.id
user.code = ''
user.save()
user = User.select().where(User.telegram_id == message.from_user.id).first()
if user is None:
await message.answer('Пользователь не найден, обратитесь к администратору за ссылкой!')
return
await message.answer(f"Приветствуем в боте NwXray! Здесь вы сможете получить информацию о своем подключении к NwXray.\n\n{get_subscription_info(message.from_user.id)}",
reply_markup=self.__non_admin_main_menu(), parse_mode=ParseMode.MARKDOWN)
@self.router.callback_query(F.data == 'update')
async def update_data(callback: CallbackQuery):
text = get_subscription_info(callback.from_user.id)
try:
await callback.message.edit_text(get_subscription_info(callback.from_user.id),
reply_markup=self.__non_admin_main_menu(), parse_mode=ParseMode.MARKDOWN)
except:
await callback.answer('Обновлений нет', show_alert=True)
return

View File

@@ -0,0 +1,20 @@
from aiogram import types
from aiogram.enums import ParseMode
from aiogram.filters import Command
from nwxraybot.meta import Handler
from nwxraybot.models import User
class MenuHandler(Handler):
def __init__(self) -> None:
super().__init__()
# TODO: Temporary disabled
# @self.router.message(Command('menu'))
async def menu(message: types.Message) -> None:
user: User = User.select().where(User.id == message.from_user.id).first()
if user:
await message.reply(f"Ваша ссылка:\n```{user.url}```", parse_mode=ParseMode.MARKDOWN_V2)
else:
await message.reply("Пользователь не найден, обратитесь к админу!")

View File

@@ -1,15 +1,16 @@
from aiogram import Router from typing import Optional
from nwxraybot import NwXrayBot from aiogram import Bot, Router
class Handler: class Handler:
bot: NwXrayBot bot: Optional[Bot]
router: Router router: Router
def __init__(self, bot: NwXrayBot) -> None: def __init__(self, bot: Optional[Bot] = None) -> None:
assert isinstance(bot, NwXrayBot) if bot:
self.bot = bot assert isinstance(bot, Bot)
self.bot = bot
self.router = Router() self.router = Router()
def __call__(self) -> Router: def __call__(self) -> Router:

View File

@@ -0,0 +1,2 @@
from nwxraybot.middlewares.admin import AdminMiddleware
from nwxraybot.middlewares.user import UserMiddleware

View File

@@ -0,0 +1,19 @@
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
from aiogram.types import Message
from nwxraybot.models import User
class AdminMiddleware(BaseMiddleware):
def __init__(self) -> None:
pass
async def __call__(self, handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], event: Message, data: Dict[str, Any]) -> Any:
user: User = User.select().where(
User.telegram_id == event.from_user.id).first()
if user is None or not user.admin:
await event.reply('Вы не обладаете правами администратора для доступа к данной команде.')
return None
return await handler(event, data)

View File

@@ -0,0 +1,24 @@
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
from aiogram.enums import ChatType
from aiogram.types import Message
from nwxraybot.models import User
class UserMiddleware(BaseMiddleware):
def __init__(self) -> None:
pass
async def __call__(self, handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], event: Message, data: Dict[str, Any]) -> Any:
if event.chat.type != ChatType.PRIVATE:
return None
if event.text and event.text.startswith('/start'):
return await handler(event, data)
user: Optional[User] = User.select().where(
User.telegram_id == event.from_user.id).first()
if user is None:
await event.answer("Вы не зарегистрированы в системе, обратитесь к админам за доступом!")
return None
return await handler(event, data)

View File

@@ -12,9 +12,9 @@ class User(Model):
name = CharField() name = CharField()
url = CharField() url = CharField()
time = DateTimeField(null=True) time = DateTimeField(null=True)
admin = BooleanField(default=False)
telegram_id = CharField(null=True)
code = CharField()
class Meta: class Meta:
database = db database = db
User.create_table()

View File

@@ -0,0 +1 @@
from nwxraybot.notifiers.subscription import setup_subscription_notifier

View File

@@ -0,0 +1,35 @@
import asyncio
import datetime
from aiogram import Bot
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from nwxraybot.models import User
async def check_subscription_status(bot: Bot):
# Check users subscription status
now_time = datetime.datetime.now()
for user in User.select().where((User.time > now_time) & ((User.time - now_time <= datetime.timedelta(days=7))) & (User.telegram_id != '')):
text = ""
delta = user.time - now_time
print(delta)
if delta.days <= 1:
text = "Скоро истекает срок действия вашей подписки. Не теряйте доступ к NwaifuVPN — продлите подписку прямо сейчас!"
elif delta.days <= 3:
text = "До окончания вашей подписки осталось менее 3-х дней. Продлите, чтобы избежать отключения."
else:
text = "Ваша подписка заканчивается через менее чем 7 дней. Продлите её, чтобы избежать отключения."
await bot.send_message(user.telegram_id, text)
# Check non-paying users
for user in User.select().where((User.time < now_time) & (now_time - User.time < datetime.timedelta(days=1)) & (User.telegram_id != '')):
await bot.send_message(user.telegram_id, "Ваша подписка истекла. Чтобы восстановить доступ к NwaifuVPN, продлите подписку уже сегодня")
def setup_subscription_notifier(bot: Bot) -> None:
scheduler = AsyncIOScheduler(event_loop=asyncio.get_event_loop())
scheduler.add_job(check_subscription_status,
'cron', hour=10, minute=0, args=[bot])
scheduler.start()

22
nwxraybot/utils.py Normal file
View File

@@ -0,0 +1,22 @@
import logging
from datetime import datetime
from secrets import token_urlsafe
from typing import Optional
from nwxraybot.models import User
def get_subscription_info(telegram_id: str) -> str:
user: User = User.select().where(User.telegram_id == telegram_id).first()
if user is None:
logging.error("[get_subscription_info]: User is not found")
return "Ошибка!"
date: Optional[datetime] = user.time
date_str = "" if date is None else f'До: {
date.strftime("%d.%m.%Y %H:%M")} МСК\n'
res = f"Информация о подписке:\n{date_str}Ссылка: `{user.url}`"
return res
def get_code(length: int = 10) -> str:
return token_urlsafe(length)[:length]

59
poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
@@ -185,6 +185,33 @@ files = [
{file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"},
] ]
[[package]]
name = "apscheduler"
version = "3.11.0"
description = "In-process task scheduler with Cron-like capabilities"
optional = false
python-versions = ">=3.8"
files = [
{file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"},
{file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"},
]
[package.dependencies]
tzlocal = ">=3.0"
[package.extras]
doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"]
etcd = ["etcd3", "protobuf (<=3.21.0)"]
gevent = ["gevent"]
mongodb = ["pymongo (>=3.0)"]
redis = ["redis (>=3.0)"]
rethinkdb = ["rethinkdb (>=2.4.0)"]
sqlalchemy = ["sqlalchemy (>=1.4)"]
test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6", "anyio (>=4.5.2)", "gevent", "pytest", "pytz", "twisted"]
tornado = ["tornado (>=4.3)"]
twisted = ["twisted"]
zookeeper = ["kazoo"]
[[package]] [[package]]
name = "attrs" name = "attrs"
version = "24.3.0" version = "24.3.0"
@@ -882,6 +909,34 @@ files = [
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
] ]
[[package]]
name = "tzdata"
version = "2024.2"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
files = [
{file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"},
{file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"},
]
[[package]]
name = "tzlocal"
version = "5.2"
description = "tzinfo object for the local timezone"
optional = false
python-versions = ">=3.8"
files = [
{file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"},
{file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"},
]
[package.dependencies]
tzdata = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
[[package]] [[package]]
name = "uvloop" name = "uvloop"
version = "0.21.0" version = "0.21.0"
@@ -1085,4 +1140,4 @@ propcache = ">=0.2.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.13" python-versions = "^3.13"
content-hash = "9b455b4a27b4038063fad5d023c0d2af06d7277a9b7ee02cc2d366e458f195b4" content-hash = "57654abddded59b21f10fc936a3f75bf5897bc0fd9ebcc81da38a5699d7ca90a"

View File

@@ -14,6 +14,7 @@ peewee = "^3.17.8"
uvloop = "^0.21.0" uvloop = "^0.21.0"
jurigged = "^0.6.0" jurigged = "^0.6.0"
psycopg2-binary = "^2.9.10" psycopg2-binary = "^2.9.10"
apscheduler = "^3.11.0"
[build-system] [build-system]

7
run.sh
View File

@@ -1,7 +0,0 @@
#!/bin/sh
set -e
export $(grep -E -v '^#' .env | xargs)
docker stack deploy -c docker-compose.yml nwxraybot