Compare commits

...

18 Commits

Author SHA1 Message Date
17a1e4bb36 Feat: change deployment
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2025-01-22 13:25:45 +03:00
0faff426a7 Feat: update woodpecker
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2025-01-22 12:59:35 +03:00
970a0e6fd4 Hotfix: pg_isready healthcheck
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-01-20 15:27:31 +03:00
c13eb62770 Feat: url correcter util
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-01-20 13:44:54 +03:00
0fa538d079 Feat: get users command
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-01-17 14:24:41 +03:00
fed548e9e8 Feat: admin help command 2025-01-17 14:10:02 +03:00
3ad4c2ea3e First hotfix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2025-01-17 14:04:35 +03:00
e3c7f96693 Merge branch 'hotfix-start-command' 2025-01-17 13:52:33 +03:00
e82c392fc8 Hotfix: user deleting tg_id 2025-01-17 13:51:57 +03:00
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
23 changed files with 362 additions and 17 deletions

View File

@@ -5,5 +5,6 @@ POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
DOMAIN_NAME=localhost
COMPOSE_PROJECT_NAME=nwxraybot

View File

@@ -1,6 +1,5 @@
when:
- branch: [master, deploy]
event: [push, manual]
steps:
- name: build
image: docker:latest
@@ -24,6 +23,7 @@ steps:
environment:
ENV_FILE:
from_secret: ENV_FILE
depends_on: [build]
depends_on: build
when:
- branch: deploy
- event: manual

View File

@@ -22,9 +22,9 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
ports:
- 5432
- "127.0.0.1:${POSTGRES_PORT}:5432"
healthcheck:
test: ["CMD", "pg_isready"]
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 10

View File

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

View File

@@ -1,2 +1,4 @@
from nwxraybot.bot import NwXrayBot
from nwxraybot.config import Settings
from nwxraybot.utils import (get_code, get_correct_user_url,
get_subscription_info)

View File

@@ -1,6 +1,8 @@
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
@@ -10,6 +12,8 @@ class NwXrayBot:
self.bot = Bot(token=token)
self.dp = Dispatcher()
self.dp.message.middleware(UserMiddleware())
self.dp.message.middleware(CallbackAnswerMiddleware())
setup_subscription_notifier(self.bot)
def include_routers(self, *routers: Handler):
for router in routers:

View File

@@ -10,6 +10,7 @@ class Settings(BaseSettings):
postgres_db: str = Field('db', env="POSTGRES_DB")
postgres_host: str = Field('localhost', env="POSTGRES_HOST")
postgres_port: int = Field(5432, env="POSTGRES_PORT")
domain_name: str = Field('localhost', env="DOMAIN_NAME")
@property
def postgres_url(self) -> str:

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,2 +1,3 @@
from nwxraybot.handlers.admin import AdminHandler
from nwxraybot.handlers.hello import HelloHandler
from nwxraybot.handlers.menu import MenuHandler

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

@@ -0,0 +1,135 @@
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
from nwxraybot.utils import get_correct_user_url
class AdminHandler(Handler):
def __init__(self, bot: Optional[Bot] = None) -> None:
super().__init__(bot)
self.router.message.middleware(AdminMiddleware())
help_text = """Список команда администратора:
`/adduser name url [01.01.1970 00:00]` - добавить пользователя
`/updateuser name 01.01.1970 00:00` - обновить информацию о пользователе
`/broadcast` - рассылка (пошагово)
`/get_users` - список пользователей
"""
@self.router.message(Command('ahelp'))
async def help(message: Message):
await message.reply(help_text, parse_mode=ParseMode.MARKDOWN)
@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()
url = user_dict['url']
url = get_correct_user_url(url)
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=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('get_users'))
async def get_users(message: Message):
def get_user_info(user: User) -> str:
date_str = "" if user.time is None else f'До: {
user.time.strftime("%d.%m.%Y %H:%M")} МСК\n'
return f"Информация о пользователе `{user.name}`:\n{date_str}Ссылка: `{user.url}`\ncode: `{user.code if user.code else 'None'}`\n\n"
users = User.select()
res = ""
for user in users:
res += get_user_info(user)
try:
await message.answer(res, parse_mode=ParseMode.MARKDOWN)
except Exception as e:
await message.answer(f"Error while getting users: {e}")
logging.error(f"Error while getting user {
user.telegram_id}: {e}")
@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,5 +1,4 @@
import json
from datetime import datetime
from typing import Optional
from aiogram import F, types
from aiogram.enums import ParseMode
@@ -7,6 +6,7 @@ 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.models import User
@@ -14,7 +14,7 @@ from nwxraybot.models import User
class HelloHandler(Handler):
def __non_admin_main_menu(self):
markup = [[
InlineKeyboardButton(text="Обновить данные",
InlineKeyboardButton(text="Обновить данные🔄",
callback_data='update')
]]
return InlineKeyboardMarkup(
@@ -26,8 +26,40 @@ class HelloHandler(Handler):
@self.router.message(Command("start"))
async def hello(message: types.Message):
await message.reply("Приветствуем в боте NwXray! Здесь вы сможете получить информацию о своем подключении к NwXray", reply_markup=self.__non_admin_main_menu())
data = message.text.split()
user: Optional[User] = None
if len(data) == 2:
code = data[1]
user = User.select().where(
User.telegram_id == message.from_user.id).first()
if user is not None:
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)
return
user = User.select().where(
User.code == code).first()
if user is None:
await message.answer('Пользователь не найден, обратитесь к администратору за ссылкой!')
return
query = User.update(telegram_id=None).where(
User.telegram_id == message.from_user.id)
query.execute()
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):
await callback.message.delete()
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

@@ -1,5 +1,6 @@
from aiogram import types
from aiogram.enums import ParseMode
from aiogram.filters import Command
from nwxraybot.meta import Handler
from nwxraybot.models import User
@@ -9,7 +10,8 @@ class MenuHandler(Handler):
def __init__(self) -> None:
super().__init__()
@self.router.message()
# 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:

View File

@@ -9,8 +9,8 @@ class Handler:
def __init__(self, bot: Optional[Bot] = None) -> None:
if bot:
assert isinstance(bot.bot, Optional[Bot])
self.bot = bot.bot
assert isinstance(bot, Bot)
self.bot = bot
self.router = Router()
def __call__(self) -> Router:

View File

@@ -1 +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

@@ -14,8 +14,10 @@ class UserMiddleware(BaseMiddleware):
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.id == event.from_user.id).first()
User.telegram_id == event.from_user.id).first()
if user is None:
await event.answer("Вы не зарегистрированы в системе, обратитесь к админам за доступом!")
return None

View File

@@ -13,6 +13,8 @@ class User(Model):
url = CharField()
time = DateTimeField(null=True)
admin = BooleanField(default=False)
telegram_id = CharField(null=True)
code = CharField()
class Meta:
database = db

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()

39
nwxraybot/utils.py Normal file
View File

@@ -0,0 +1,39 @@
import logging
from datetime import datetime
from secrets import token_urlsafe
from typing import Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from nwxraybot import Settings
from nwxraybot.models import User
config = Settings()
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]
def get_correct_user_url(url: str) -> str:
parsed_url = urlparse(url)
query = parse_qs(parsed_url.query)
query['fp'] = 'chrome'
query['alpn'] = 'h2,h3'
query['packetEncoding'] = 'xudp'
query['security'] = 'tls'
new_query = urlencode(query, doseq=True)
new_url = urlunparse((parsed_url.scheme, parsed_url.netloc.replace("127.0.0.1:1234", f"{
config.domain_name}:443"), parsed_url.path, parsed_url.params, new_query, parsed_url.fragment))
return new_url

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]]
name = "aiofiles"
@@ -185,6 +185,33 @@ files = [
{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]]
name = "attrs"
version = "24.3.0"
@@ -882,6 +909,34 @@ files = [
{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]]
name = "uvloop"
version = "0.21.0"
@@ -1085,4 +1140,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.13"
content-hash = "9b455b4a27b4038063fad5d023c0d2af06d7277a9b7ee02cc2d366e458f195b4"
content-hash = "57654abddded59b21f10fc936a3f75bf5897bc0fd9ebcc81da38a5699d7ca90a"

View File

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