adding servers for special groups/ ruff formatting
This commit is contained in:
@@ -111,6 +111,7 @@ class Server(DictLikeMixin, Base):
|
||||
enabled = Column(Boolean, default=True)
|
||||
|
||||
subgroups = relationship("ServerSubgroup", back_populates="server", cascade="all, delete-orphan")
|
||||
groups = relationship("ServerSpecialgroup", back_populates="server", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ServerSubgroup(DictLikeMixin, Base):
|
||||
@@ -126,6 +127,18 @@ class ServerSubgroup(DictLikeMixin, Base):
|
||||
__table_args__ = (UniqueConstraint("server_id", "subgroup_title", name="uq_server_subgroup"),)
|
||||
|
||||
|
||||
class ServerSpecialgroup(DictLikeMixin, Base):
|
||||
__tablename__ = "server_specialgroups"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
|
||||
group_code = Column(String, nullable=False)
|
||||
|
||||
server = relationship("Server")
|
||||
|
||||
__table_args__ = (UniqueConstraint("server_id", "group_code", name="uq_server_group"),)
|
||||
|
||||
|
||||
class Payment(DictLikeMixin, Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
|
||||
+15
-1
@@ -2,7 +2,7 @@ from sqlalchemy import delete, func, insert, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Server, ServerSubgroup, Tariff
|
||||
from database.models import Key, Server, ServerSpecialgroup, ServerSubgroup, Tariff
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -58,11 +58,24 @@ async def get_servers(session: AsyncSession, include_enabled: bool = False) -> d
|
||||
for sid, sg in r.all():
|
||||
subs_map.setdefault(sid, []).append(sg)
|
||||
|
||||
groups_map = {}
|
||||
if ids:
|
||||
r2 = await session.execute(
|
||||
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
|
||||
ServerSpecialgroup.server_id.in_(ids)
|
||||
)
|
||||
)
|
||||
for sid, gc in r2.all():
|
||||
groups_map.setdefault(sid, []).append(gc)
|
||||
|
||||
allowed = {"trial", "discounts", "discounts_max"}
|
||||
|
||||
grouped = {}
|
||||
for s in servers:
|
||||
if not include_enabled and not s.enabled:
|
||||
continue
|
||||
cluster = s.cluster_name
|
||||
special = sorted({g for g in groups_map.get(s.id, []) if g in allowed})
|
||||
grouped.setdefault(cluster, []).append({
|
||||
"server_name": s.server_name,
|
||||
"api_url": s.api_url,
|
||||
@@ -73,6 +86,7 @@ async def get_servers(session: AsyncSession, include_enabled: bool = False) -> d
|
||||
"max_keys": s.max_keys,
|
||||
"tariff_group": s.tariff_group,
|
||||
"tariff_subgroups": subs_map.get(s.id, []),
|
||||
"special_groups": special,
|
||||
"cluster_name": cluster,
|
||||
})
|
||||
return grouped
|
||||
|
||||
@@ -8,7 +8,7 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from py3xui import AsyncApi
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy import and_, delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
@@ -19,7 +19,7 @@ from config import (
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import check_unique_server_name, get_servers, update_key_expiry
|
||||
from database.models import Key, Server, ServerSubgroup, Tariff
|
||||
from database.models import Key, Server, ServerSpecialgroup, ServerSubgroup, Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.operations import (
|
||||
create_client_on_server,
|
||||
@@ -27,6 +27,7 @@ from handlers.keys.operations import (
|
||||
delete_key_from_cluster,
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from handlers.utils import ALLOWED_GROUP_CODES
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from utils.backup import create_backup_and_send_to_admins
|
||||
@@ -35,12 +36,15 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminClusterCallback,
|
||||
AdminServerCallback,
|
||||
build_attach_tariff_kb,
|
||||
build_cluster_management_kb,
|
||||
build_clusters_editor_kb,
|
||||
build_manage_cluster_kb,
|
||||
build_panel_type_kb,
|
||||
build_select_group_servers_kb,
|
||||
build_select_subgroup_servers_kb,
|
||||
build_sync_cluster_kb,
|
||||
build_tariff_group_selection_for_servers_kb,
|
||||
build_tariff_group_selection_kb,
|
||||
build_tariff_subgroup_selection_kb,
|
||||
)
|
||||
@@ -322,16 +326,24 @@ async def handle_cluster_servers(callback: CallbackQuery, session: AsyncSession)
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
allowed = {"trial", "discounts", "discounts_max"}
|
||||
lines = []
|
||||
for s in cluster_servers:
|
||||
subs = s.get("tariff_subgroups") or []
|
||||
subs_str = ", ".join(sorted(subs)) if subs else "—"
|
||||
lines.append(f"• {s.get('server_name', '?')} — {subs_str}")
|
||||
|
||||
grps = s.get("special_groups") or []
|
||||
grps = [g for g in grps if g in allowed]
|
||||
grps_str = ", ".join(sorted(grps)) if grps else "—"
|
||||
|
||||
lines.append(f"• {s.get('server_name', '?')} — {subs_str} | {grps_str}")
|
||||
|
||||
details = "\n".join(lines) if lines else "нет серверов"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=(f"<b>📡 Серверы в кластере {cluster_name}</b>\n<i>подгруппы:</i>\n<blockquote>{details}</blockquote>"),
|
||||
text=(
|
||||
f"<b>📡 Серверы в кластере {cluster_name}</b>\n<i>подгруппы | спецгруппы:</i>\n<blockquote>{details}</blockquote>"
|
||||
),
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
@@ -1309,3 +1321,211 @@ async def reset_cluster_subgroups(callback: CallbackQuery, callback_data: AdminC
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сбросе подгрупп для кластера {cluster_name}: {e}")
|
||||
await callback.message.edit_text("❌ Не удалось сбросить подгруппы.")
|
||||
|
||||
|
||||
def render_attach_tariff_menu_text(cluster_name: str, cluster_servers: list[dict]) -> str:
|
||||
sub_map: dict[str, list[str]] = {}
|
||||
for s in cluster_servers:
|
||||
for sg in s.get("tariff_subgroups") or []:
|
||||
sub_map.setdefault(sg, []).append(s["server_name"])
|
||||
|
||||
allowed = ("trial", "discounts", "discounts_max")
|
||||
spec_map: dict[str, list[str]] = {k: [] for k in allowed}
|
||||
for s in cluster_servers:
|
||||
for g in s.get("special_groups") or []:
|
||||
if g in spec_map:
|
||||
spec_map[g].append(s["server_name"])
|
||||
|
||||
lines = [f"<b>🧩 Привязки тарифов • {cluster_name}</b>"]
|
||||
|
||||
lines.append("<b>Подгруппы:</b>")
|
||||
if sub_map:
|
||||
subs_lines = []
|
||||
for k in sorted(sub_map):
|
||||
servers_list = ", ".join(sorted(set(sub_map[k])))
|
||||
subs_lines.append(f"• <b>{k}</b>: {servers_list}")
|
||||
lines.append("<blockquote>\n" + "\n".join(subs_lines) + "\n</blockquote>")
|
||||
else:
|
||||
lines.append("<blockquote>— нет привязок</blockquote>")
|
||||
|
||||
lines.append("<b>Спецгруппы:</b>")
|
||||
has_spec = any(spec_map[k] for k in allowed)
|
||||
if has_spec:
|
||||
spec_lines = []
|
||||
for k in allowed:
|
||||
vals = sorted(set(spec_map[k]))
|
||||
spec_lines.append(f"• <b>{k}</b>: {', '.join(vals) if vals else '—'}")
|
||||
lines.append("<blockquote>\n" + "\n".join(spec_lines) + "\n</blockquote>")
|
||||
else:
|
||||
lines.append("<blockquote>— нет привязок</blockquote>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "attach_tariff_menu"), IsAdminFilter())
|
||||
async def handle_attach_tariff_menu(callback: CallbackQuery, session: AsyncSession):
|
||||
packed = AdminClusterCallback.unpack(callback.data)
|
||||
cluster_name = packed.data
|
||||
|
||||
servers = await get_servers(session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
|
||||
await callback.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_attach_tariff_kb(cluster_name),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "set_group"))
|
||||
async def show_servers_for_group(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(f"grp_sel:{cluster_name}", []))
|
||||
await callback.message.edit_text(
|
||||
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
|
||||
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_group"))
|
||||
async def toggle_server_for_group(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name, idx_str = callback_data.data.split("|", 1)
|
||||
i = int(idx_str)
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
names = []
|
||||
for s in cluster_servers:
|
||||
if isinstance(s, str):
|
||||
names.append(s)
|
||||
elif isinstance(s, dict):
|
||||
names.append(s.get("server_name") or s.get("name") or str(s))
|
||||
else:
|
||||
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
|
||||
if i < 0 or i >= len(names):
|
||||
await callback.answer("Сервер не найден", show_alert=True)
|
||||
return
|
||||
server_name = names[i]
|
||||
key = f"grp_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(key, []))
|
||||
if server_name in selected:
|
||||
selected.remove(server_name)
|
||||
else:
|
||||
selected.add(server_name)
|
||||
await state.update_data({key: list(selected)})
|
||||
await callback.message.edit_text(
|
||||
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
|
||||
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_group_selection"))
|
||||
async def reset_group_selection(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
await state.update_data({f"grp_sel:{cluster_name}": []})
|
||||
await callback.message.edit_text(
|
||||
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
|
||||
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, set()),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_group"))
|
||||
async def choose_group(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
key = f"grp_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(key, []))
|
||||
if not selected:
|
||||
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
|
||||
return
|
||||
groups = [(i, code) for i, code in enumerate(ALLOWED_GROUP_CODES)]
|
||||
await callback.message.edit_text(
|
||||
f"<b>📚 Выберите группу для {len(selected)} сервер(а/ов) кластера <code>{cluster_name}</code>:</b>",
|
||||
reply_markup=build_tariff_group_selection_for_servers_kb(cluster_name, groups),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_group_to_servers"))
|
||||
async def apply_group_to_servers(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
cluster_name, idx_str = callback_data.data.split("|", 1)
|
||||
i = int(idx_str)
|
||||
groups = ALLOWED_GROUP_CODES
|
||||
if i < 0 or i >= len(groups):
|
||||
await callback.answer("Группа не найдена", show_alert=True)
|
||||
return
|
||||
group_code = groups[i]
|
||||
|
||||
key = f"grp_sel:{cluster_name}"
|
||||
data = await state.get_data()
|
||||
selected = set(data.get(key, []))
|
||||
if not selected:
|
||||
await callback.message.edit_text("❌ Не выбраны серверы для назначения группы.")
|
||||
return
|
||||
|
||||
rows = await session.execute(select(Server.id, Server.server_name).where(Server.server_name.in_(selected)))
|
||||
id_by_name = {name: sid for sid, name in rows.fetchall()}
|
||||
server_ids = [id_by_name[n] for n in selected if n in id_by_name]
|
||||
if not server_ids:
|
||||
await callback.answer("Серверы не найдены", show_alert=True)
|
||||
return
|
||||
|
||||
exist_rows = await session.execute(
|
||||
select(ServerSpecialgroup.server_id).where(
|
||||
and_(ServerSpecialgroup.server_id.in_(server_ids), ServerSpecialgroup.group_code == group_code)
|
||||
)
|
||||
)
|
||||
already = {r[0] for r in exist_rows.fetchall()}
|
||||
to_insert = [sid for sid in server_ids if sid not in already]
|
||||
|
||||
if to_insert:
|
||||
session.add_all([ServerSpecialgroup(server_id=sid, group_code=group_code) for sid in to_insert])
|
||||
await session.commit()
|
||||
|
||||
await state.update_data({key: []})
|
||||
applied = ", ".join(sorted(selected))
|
||||
await callback.message.edit_text(
|
||||
f"✅ Группа <b>{group_code}</b> назначена серверам:\n<blockquote>{applied}</blockquote>",
|
||||
reply_markup=build_cluster_management_kb(cluster_name),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при назначении группы тарифов: {e}")
|
||||
await callback.message.edit_text("❌ Произошла ошибка при назначении группы.")
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_groups"))
|
||||
async def reset_cluster_groups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
|
||||
try:
|
||||
cluster_name = callback_data.data
|
||||
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
|
||||
server_ids = [row[0] for row in res.fetchall()]
|
||||
if not server_ids:
|
||||
await callback.answer("В кластере нет серверов", show_alert=True)
|
||||
return
|
||||
await session.execute(delete(ServerSpecialgroup).where(ServerSpecialgroup.server_id.in_(server_ids)))
|
||||
await session.commit()
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
await callback.message.edit_text(
|
||||
f"✅ Все привязки групп сброшены для кластера <b>{cluster_name}</b>.",
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сбросе групп для кластера {cluster_name}: {e}")
|
||||
await callback.message.edit_text("❌ Не удалось сбросить привязки групп.")
|
||||
|
||||
@@ -57,21 +57,12 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
|
||||
callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗂 Выбрать подгруппу тарифов",
|
||||
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
|
||||
text="💸 Тариф(Установить/изменить)",
|
||||
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🧹 Сбросить все подгруппы",
|
||||
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
@@ -82,6 +73,37 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_attach_tariff_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗂 Спецгруппы",
|
||||
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🧹 Сбросить",
|
||||
callback_data=AdminClusterCallback(action="reset_cluster_groups", data=cluster_name).pack(),
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗂 Подгруппы",
|
||||
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🧹 Сбросить",
|
||||
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_select_subgroup_servers_kb(
|
||||
cluster_name: str, cluster_servers: list, selected: set[str]
|
||||
) -> InlineKeyboardMarkup:
|
||||
@@ -242,3 +264,65 @@ def build_tariff_group_selection_kb(cluster_name: str, groups: list[tuple[int, s
|
||||
)
|
||||
builder.adjust(2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_select_group_servers_kb(cluster_name: str, cluster_servers: list, selected: set[str]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
names = []
|
||||
for s in cluster_servers:
|
||||
if isinstance(s, str):
|
||||
names.append(s)
|
||||
elif isinstance(s, dict):
|
||||
names.append(s.get("server_name") or s.get("name") or str(s))
|
||||
else:
|
||||
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
|
||||
|
||||
for i, name in enumerate(names):
|
||||
mark = "✅" if name in selected else "⬜️"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{mark} {name}",
|
||||
callback_data=AdminClusterCallback(action="toggle_server_group", data=f"{cluster_name}|{i}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📚 Выбрать спецгруппу",
|
||||
callback_data=AdminClusterCallback(action="choose_group", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="♻️ Сбросить выбор",
|
||||
callback_data=AdminClusterCallback(action="reset_group_selection", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_tariff_group_selection_for_servers_kb(
|
||||
cluster_name: str, groups: list[tuple[int, str]]
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for group_id, group_code in groups:
|
||||
builder.button(
|
||||
text=group_code,
|
||||
callback_data=AdminClusterCallback(
|
||||
action="apply_group_to_servers", data=f"{cluster_name}|{group_id}"
|
||||
).pack(),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.adjust(2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -56,11 +56,13 @@ async def handle_modules(callback_query: CallbackQuery, state: FSMContext, sessi
|
||||
total_pages = max(1, (len(items) + per_page - 1) // per_page)
|
||||
page = min(page, total_pages)
|
||||
start = (page - 1) * per_page
|
||||
chunk = items[start:start + per_page]
|
||||
chunk = items[start : start + per_page]
|
||||
|
||||
if chunk:
|
||||
|
||||
def fmt(n, v):
|
||||
return f"{n} v{v}" if v else n
|
||||
|
||||
lines = "\n".join(f"• {fmt(n, v)}" for n, v in chunk)
|
||||
text = f"🧩 Мои модули\n\nНайдено: {len(items)}\n<blockquote>{lines}</blockquote>"
|
||||
else:
|
||||
|
||||
@@ -45,24 +45,24 @@ async def send_broadcast_batch(bot, messages, batch_size=15, session=None):
|
||||
|
||||
try:
|
||||
if photo:
|
||||
result = await bot.send_photo(
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
result = await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
results.append(True)
|
||||
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
retry_in = int(e.retry_after) + 1
|
||||
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек. для пользователя {tg_id}")
|
||||
await asyncio.sleep(e.retry_after)
|
||||
try:
|
||||
if photo:
|
||||
result = await bot.send_photo(
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
result = await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
results.append(True)
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
@@ -79,7 +79,7 @@ async def send_broadcast_batch(bot, messages, batch_size=15, session=None):
|
||||
logger.error(f"❌ Ошибка повторной отправки пользователю {tg_id}: {retry_error}")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
results.append(False)
|
||||
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
@@ -147,9 +147,7 @@ async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "hotleads":
|
||||
subquery_active_keys = (
|
||||
select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
|
||||
)
|
||||
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.join(Payment, User.tg_id == Payment.tg_id)
|
||||
|
||||
@@ -458,7 +458,7 @@ async def handle_balance_add(
|
||||
if amount is not None:
|
||||
amount = int(amount)
|
||||
old_balance = await get_balance(session, tg_id)
|
||||
|
||||
|
||||
if amount >= 0:
|
||||
await update_balance(session, tg_id, amount)
|
||||
new_balance = old_balance + amount
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import bot
|
||||
from config import DISCOUNT_ACTIVE_HOURS, USE_NEW_PAYMENT_FLOW, RENEW_BUTTON_BEFORE_DAYS
|
||||
from config import DISCOUNT_ACTIVE_HOURS, RENEW_BUTTON_BEFORE_DAYS, USE_NEW_PAYMENT_FLOW
|
||||
from database import (
|
||||
check_tariff_exists,
|
||||
get_balance,
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config import HAPP_CRYPTOLINK, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
|
||||
from database import get_servers, get_tariff_by_id, store_key
|
||||
from database.models import User
|
||||
from handlers.utils import check_server_key_limit
|
||||
from handlers.utils import ALLOWED_GROUP_CODES, check_server_key_limit
|
||||
from logger import logger
|
||||
from panels._3xui import (
|
||||
ClientConfig,
|
||||
@@ -73,6 +73,26 @@ async def create_key_on_cluster(
|
||||
subgroup_servers = [s for s in enabled_servers if subgroup_title in s.get("tariff_subgroups", [])]
|
||||
if subgroup_servers:
|
||||
enabled_servers = subgroup_servers
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Key Creation] В кластере {cluster_id} не найдено серверов для подгруппы '{subgroup_title}'. Использую весь кластер."
|
||||
)
|
||||
|
||||
special = None
|
||||
if is_trial:
|
||||
special = "trial"
|
||||
elif tariff:
|
||||
gc = (tariff.get("group_code") or "").lower()
|
||||
if gc in ALLOWED_GROUP_CODES:
|
||||
special = gc
|
||||
if special:
|
||||
bound_servers = [s for s in enabled_servers if special in (s.get("special_groups") or [])]
|
||||
if bound_servers:
|
||||
enabled_servers = bound_servers
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Key Creation] В кластере {cluster_id} нет серверов со спецгруппой '{special}'. Использую весь кластер."
|
||||
)
|
||||
|
||||
remnawave_servers = [
|
||||
s
|
||||
|
||||
@@ -9,16 +9,16 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from config import (
|
||||
NOTIFICATION_TIME,
|
||||
NOTIFY_10H_ENABLED,
|
||||
NOTIFY_10H_HOURS,
|
||||
NOTIFY_24H_ENABLED,
|
||||
NOTIFY_24H_HOURS,
|
||||
NOTIFY_DELETE_DELAY,
|
||||
NOTIFY_DELETE_KEY,
|
||||
NOTIFY_HOT_LEADS,
|
||||
NOTIFY_INACTIVE_TRAFFIC,
|
||||
NOTIFY_RENEW,
|
||||
NOTIFY_RENEW_EXPIRED,
|
||||
NOTIFY_24H_ENABLED,
|
||||
NOTIFY_24H_HOURS,
|
||||
NOTIFY_10H_ENABLED,
|
||||
NOTIFY_10H_HOURS,
|
||||
TRIAL_TIME_DISABLE,
|
||||
)
|
||||
from database import (
|
||||
@@ -94,14 +94,18 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||
|
||||
if NOTIFY_24H_ENABLED:
|
||||
try:
|
||||
threshold_24h = int((datetime.now(moscow_tz) + timedelta(hours=NOTIFY_24H_HOURS)).timestamp() * 1000)
|
||||
threshold_24h = int(
|
||||
(datetime.now(moscow_tz) + timedelta(hours=NOTIFY_24H_HOURS)).timestamp() * 1000
|
||||
)
|
||||
await notify_24h_keys(bot, session, current_time, threshold_24h, keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_24h_keys: {e}")
|
||||
|
||||
if NOTIFY_10H_ENABLED:
|
||||
try:
|
||||
threshold_10h = int((datetime.now(moscow_tz) + timedelta(hours=NOTIFY_10H_HOURS)).timestamp() * 1000)
|
||||
threshold_10h = int(
|
||||
(datetime.now(moscow_tz) + timedelta(hours=NOTIFY_10H_HOURS)).timestamp() * 1000
|
||||
)
|
||||
await notify_10h_keys(bot, session, current_time, threshold_10h, keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_10h_keys: {e}")
|
||||
@@ -165,7 +169,7 @@ async def notify_24h_keys(
|
||||
continue
|
||||
|
||||
expiry_data = await prepare_key_expiry_data(key, session, current_time)
|
||||
|
||||
|
||||
notification_text = KEY_EXPIRY.format(
|
||||
email=email,
|
||||
hours_left_formatted=expiry_data["hours_left_formatted"],
|
||||
@@ -249,7 +253,7 @@ async def notify_10h_keys(
|
||||
continue
|
||||
|
||||
expiry_data = await prepare_key_expiry_data(key, session, current_time)
|
||||
|
||||
|
||||
notification_text = KEY_EXPIRY.format(
|
||||
email=email,
|
||||
hours_left_formatted=expiry_data["hours_left_formatted"],
|
||||
@@ -486,15 +490,17 @@ async def process_auto_renew_or_notify(
|
||||
|
||||
if not selected_tariff:
|
||||
expiry_data = await prepare_key_expiry_data(key, conn, int(datetime.now(moscow_tz).timestamp() * 1000))
|
||||
|
||||
|
||||
use_change_tariff_kb = False
|
||||
|
||||
|
||||
if tariff_id and await check_tariff_exists(conn, tariff_id):
|
||||
current_tariff = await get_tariff_by_id(conn, tariff_id)
|
||||
if current_tariff:
|
||||
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
|
||||
try:
|
||||
hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn)
|
||||
hook_results = await run_hooks(
|
||||
"renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn
|
||||
)
|
||||
for hook_result in hook_results:
|
||||
additional_groups = hook_result.get("additional_groups", [])
|
||||
forbidden_groups.extend(additional_groups)
|
||||
@@ -526,7 +532,7 @@ async def process_auto_renew_or_notify(
|
||||
keyboard = build_change_tariff_kb(email)
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
|
||||
|
||||
await add_notification(conn, tg_id, notification_id)
|
||||
await send_notification(bot, tg_id, standard_photo, message_text, keyboard)
|
||||
return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import aiofiles
|
||||
@@ -176,7 +177,7 @@ async def prepare_key_expiry_data(key, session: AsyncSession, current_time: int)
|
||||
|
||||
tariff_name = "—"
|
||||
tariff_details = ""
|
||||
|
||||
|
||||
if getattr(key, "tariff_id", None):
|
||||
tariff = await get_tariff_by_id(session, key.tariff_id)
|
||||
if tariff:
|
||||
@@ -192,7 +193,7 @@ async def prepare_key_expiry_data(key, session: AsyncSession, current_time: int)
|
||||
lines.append(f"Трафик: {traffic_text}")
|
||||
lines.append(f"Устройств: {devices_text}")
|
||||
tariff_details = "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
return {
|
||||
"hours_left_formatted": hours_left_formatted,
|
||||
"formatted_expiry_date": formatted_expiry_date,
|
||||
|
||||
@@ -25,6 +25,9 @@ from hooks.hooks import run_hooks
|
||||
from logger import logger
|
||||
|
||||
|
||||
ALLOWED_GROUP_CODES = ["trial", "discounts", "discounts_max"]
|
||||
|
||||
|
||||
async def generate_random_email(
|
||||
length: int = 8,
|
||||
session: AsyncSession | None = None,
|
||||
|
||||
@@ -37,7 +37,7 @@ class SubscriptionMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
if event.message.from_user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
tg_id = event.message.from_user.id
|
||||
message = event.message
|
||||
from_user = event.message.from_user
|
||||
@@ -48,7 +48,7 @@ class SubscriptionMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
if event.callback_query.from_user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
tg_id = event.callback_query.from_user.id
|
||||
message = event.callback_query.message
|
||||
from_user = event.callback_query.from_user
|
||||
|
||||
Reference in New Issue
Block a user