Compare commits

..
17 Commits
Author SHA1 Message Date
machka paslaandGitHub c65b608f0b Merge pull request #114 from machka-pasla/dev
more payment info in admin, docker compose for other servers, fixed channel sub check
2025-10-28 14:40:59 +03:00
machka pasla cff8368b7c docker hub push 2025-10-28 14:39:47 +03:00
machka paslaandGitHub 2f367bbd6b Merge pull request #112 from streletskiy/feature/display-user-income
Additional payment info in admin panel user card
2025-10-28 14:31:47 +03:00
machka pasla c3d6324ec6 fixed channel sub check 2025-10-27 15:58:59 +03:00
Bogdan Strielecki 4800a2da80 Additional payment info in admin panel user card
- total income
- total income from all user referrals
2025-10-27 13:38:37 +03:00
machka paslaandGitHub 70a0cd44a1 Merge pull request #111 from freize/main
An example Docker file for deploying a bot on a separate server
2025-10-25 20:02:43 +03:00
machka paslaandGitHub 545c9c7d07 Merge pull request #109 from Snowy-Fluffy/custom-payment-id
Добавление возможности выставлять ID способа оплаты для FreeKassa в env
2025-10-25 20:02:14 +03:00
FreizeandGitHub cd60f496fa Update docker-compose-removed-server.yml 2025-10-25 19:27:57 +03:00
FreizeandGitHub f883065bb9 Add Docker Compose configuration for removed server
This configuration file was created as an example showing the installation of the bot on a separate server.
2025-10-25 19:24:50 +03:00
Snowy-Fluffy b71bd71d8d allows you to set custom payment id for fk 2025-10-24 20:45:43 +03:00
Snowy-Fluffy 0524d24b13 set custom payment id for fk 2025-10-24 20:21:35 +03:00
machka paslaandGitHub cdfd94c814 Merge pull request #108 from machka-pasla/dev
fixed locales and traffic after adding days
2025-10-24 10:34:25 +03:00
machka pasla 6513681125 fixed locales and traffic after adding days 2025-10-24 10:33:10 +03:00
machka paslaandGitHub f321fd04cf Merge pull request #107
fixed ad
2025-10-21 09:45:32 +03:00
machka pasla baede17adf fixed ad 2025-10-21 09:44:21 +03:00
machka paslaandGitHub 17bf8720a3 Merge pull request #105 from machka-pasla/dev
fixed my devices keyboard
2025-10-20 22:28:36 +03:00
machka pasla 280aced20e fixed my devices keyboard 2025-10-20 22:26:50 +03:00
16 changed files with 236 additions and 33 deletions
+1
View File
@@ -50,6 +50,7 @@ FREEKASSA_MERCHANT_ID=your_shop_id #
FREEKASSA_API_KEY=your_api_key # API key for REST requests
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay
+13 -4
View File
@@ -9,8 +9,9 @@ on:
- dev
env:
REGISTRY: ghcr.io
GHCR_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs:
build:
@@ -26,18 +27,26 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
+14 -2
View File
@@ -9,6 +9,10 @@ on:
paths-ignore:
- 'README.md'
env:
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs:
build-and-push:
runs-on: ubuntu-latest
@@ -27,6 +31,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU (для эмуляции arm64 на x86)
uses: docker/setup-qemu-action@v3
@@ -40,5 +50,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
${{ env.GHCR_IMAGE }}:latest
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
${{ env.DOCKERHUB_IMAGE }}:latest
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
+3
View File
@@ -91,6 +91,7 @@
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
</details>
@@ -163,6 +164,8 @@
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
## 📁 Структура проекта
```
+14
View File
@@ -192,6 +192,20 @@ async def format_user_card(user: User, session: AsyncSession,
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал")
card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}")
# Financial analytics (admin-only)
try:
from db.dal import payment_dal
# Total amount paid by this user
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
card_parts.append(f"{_('admin_user_total_paid_label', default='💰 <b>Всего оплачено:</b>')} {hcode(f'{total_paid:.2f} RUB')}")
# Total revenue from referrals
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
card_parts.append(f"{_('admin_user_referral_revenue_label', default='💸 <b>Доход по рефералам:</b>')} {hcode(f'{referral_revenue:.2f} RUB')}")
except Exception as e_fin:
logging.error(f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}")
# Referral stats
if referral_service is not None:
try:
+30 -1
View File
@@ -183,9 +183,38 @@ async def my_subscription_command_handler(
max_devices_display = str(max_devices_int)
except (TypeError, ValueError):
max_devices_display = str(max_devices_value)
current_devices_display = "?"
user_uuid = active.get("user_id")
devices_response = None
if user_uuid:
try:
devices_response = await panel_service.get_user_devices(user_uuid)
except Exception:
logging.exception("Failed to load devices for user %s", user_uuid)
if devices_response:
devices_count: Optional[int] = None
if isinstance(devices_response, dict):
devices_list = devices_response.get("devices")
if isinstance(devices_list, list):
devices_count = len(devices_list)
elif isinstance(devices_list, int):
devices_count = devices_list
else:
try:
devices_count = len(devices_list) # type: ignore[arg-type]
except Exception:
devices_count = None
if devices_count is None:
total_value = devices_response.get("total")
if isinstance(total_value, int):
devices_count = total_value
elif isinstance(devices_response, list):
devices_count = len(devices_response)
if devices_count is not None:
current_devices_display = str(devices_count)
devices_button_text = get_text(
"devices_button",
current_devices="?",
current_devices=current_devices_display,
max_devices=max_devices_display,
)
prepend_rows.append([
+2 -2
View File
@@ -881,10 +881,10 @@ async def pay_fk_callback_handler(
months=months,
amount=price_rub,
currency=freekassa_service.default_currency,
method_code=44,
payment_method_id=freekassa_service.payment_method_id,
ip_address=freekassa_service.server_ip,
extra_params={
"us_method": 44,
"us_method": freekassa_service.payment_method_id,
},
)
+8
View File
@@ -40,6 +40,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
if not event_user or event_user.id in self.settings.ADMIN_IDS:
return await handler(event, data)
callback_query = event.callback_query
if (
callback_query
and callback_query.data
and callback_query.data == "channel_subscription:verify"
):
return await handler(event, data)
# Allow /start to reach the handler so the check can be re-run.
message_object: Optional[Message] = event.message
if (
+3 -2
View File
@@ -45,6 +45,7 @@ class FreeKassaService:
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
self.api_base_url: str = "https://api.fk.life/v1"
self._timeout = ClientTimeout(total=15)
@@ -72,9 +73,9 @@ class FreeKassaService:
months: int,
amount: float,
currency: Optional[str],
method_code: int,
email: Optional[str] = None,
ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
@@ -94,7 +95,7 @@ class FreeKassaService:
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id),
"i": int(method_code),
"i": int(payment_method_id),
"amount": amount_str,
"currency": currency_code,
"email": email,
+23 -3
View File
@@ -568,6 +568,11 @@ class SubscriptionService:
bonus_days: int,
reason: str = "bonus",
) -> Optional[datetime]:
reason_lower = (reason or "").lower()
apply_main_traffic_limit = any(
keyword in reason_lower for keyword in ("admin", "promo code", "referral", "bonus")
)
user = await user_dal.get_user_by_id(session, user_id)
if not user:
logging.warning(
@@ -594,8 +599,12 @@ class SubscriptionService:
start_date = datetime.now(timezone.utc)
new_end_date_obj = start_date + timedelta(days=bonus_days)
# For promo code activations, use the configured user traffic limit
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise
traffic_limit = (
self.settings.user_traffic_limit_bytes
if apply_main_traffic_limit
else self.settings.trial_traffic_limit_bytes
)
bonus_sub_payload = {
"user_id": user_id,
@@ -626,12 +635,23 @@ class SubscriptionService:
session, active_sub.subscription_id, new_end_date_obj
)
if (
apply_main_traffic_limit
and updated_sub_model
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
):
updated_sub_model = await subscription_dal.update_subscription(
session,
updated_sub_model.subscription_id,
{"traffic_limit_bytes": self.settings.user_traffic_limit_bytes},
)
if updated_sub_model:
# Prepare panel update payload
panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj,
traffic_limit_bytes=(
self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else None
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
),
include_uuid=False,
)
+1
View File
@@ -57,6 +57,7 @@ class Settings(BaseSettings):
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
FREEKASSA_API_KEY: Optional[str] = None
FREEKASSA_PAYMENT_IP: Optional[str] = None
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
YOOKASSA_ENABLED: bool = Field(default=True)
STARS_ENABLED: bool = Field(default=True)
+36 -12
View File
@@ -98,23 +98,37 @@ async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[st
trials = (await session.execute(trials_stmt)).scalar() or 0
# Payers (unique users with succeeded payments)
payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where(
attrib_subq = (
select(
AdAttribution.user_id.label("user_id"),
AdAttribution.first_start_at.label("first_start_at"),
)
.where(AdAttribution.ad_campaign_id == campaign_id)
.subquery()
)
payers_stmt = (
select(func.count(func.distinct(Payment.user_id)))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.user_id.in_(
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
),
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
payers = (await session.execute(payers_stmt)).scalar() or 0
# Revenue sum
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
revenue_stmt = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.user_id.in_(
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
),
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
@@ -151,10 +165,22 @@ async def get_totals(session: AsyncSession) -> Dict[str, float]:
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
# Total revenue from all attributed users (unique users counted across all campaigns)
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
attrib_subq = (
select(
AdAttribution.user_id.label("user_id"),
AdAttribution.first_start_at.label("first_start_at"),
)
.subquery()
)
revenue_stmt = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.user_id.in_(select(AdAttribution.user_id)),
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
@@ -178,5 +204,3 @@ async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool:
except Exception as e:
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
raise
+34
View File
@@ -262,3 +262,37 @@ async def get_last_tribute_payment(
Payment.created_at.desc()).limit(1))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
stmt = select(func.sum(Payment.amount)).where(
and_(
Payment.user_id == user_id,
Payment.status == 'succeeded'
)
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
"""Get total revenue generated from referred users' payments.
This calculates the sum of all succeeded payments made by users
where referred_by_id equals the referrer_id.
"""
from db.models import User
stmt = select(func.sum(Payment.amount)).join(
User, Payment.user_id == User.user_id
).where(
and_(
User.referred_by_id == referrer_id,
Payment.status == 'succeeded'
)
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
+41
View File
@@ -0,0 +1,41 @@
services:
remnawave-tg-shop:
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
env_file:
- .env
ports:
- 8080:8080
networks:
- remnawave-network
environment:
- TZ=UTC
volumes:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
- remnawave-tg-shop-db
remnawave-tg-shop-db:
image: postgres:17
container_name: remnawave-tg-shop-db
hostname: remnawave-tg-shop-db
env_file:
- .env
environment:
- TZ=UTC
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
networks:
- remnawave-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
networks:
remnawave-network: null
volumes:
remnawave-tg-shop-db-data:
name: remnawave-tg-shop-db-data
+3
View File
@@ -257,6 +257,7 @@
"admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.",
"admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}",
"admin_user_subscription_added_error": "❌ Error adding subscription days",
"admin_panel_user_creation_failed": "❌ Failed to create or link panel user for ID {user_id}. Manual intervention required.",
"admin_user_ban_toggle_success": "✅ User {status}",
"admin_user_ban_toggle_error": "❌ Error changing ban status",
"admin_user_ban_success": "✅ User {input} has been banned",
@@ -336,6 +337,8 @@
"admin_user_na_value": "N/A",
"admin_user_subscription_none": "No active subscription",
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
"admin_user_total_paid_label": "💰 <b>Total Paid:</b>",
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
+3
View File
@@ -257,6 +257,7 @@
"admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.",
"admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
"admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки",
"admin_panel_user_creation_failed": "❌ Не удалось создать или привязать пользователя в панели для ID {user_id}. Требуется ручная проверка.",
"admin_user_ban_toggle_success": "✅ Пользователь {status}",
"admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки",
"admin_user_ban_success": "✅ Пользователь {input} заблокирован",
@@ -336,6 +337,8 @@
"admin_user_na_value": "N/A",
"admin_user_subscription_none": "Нет активной подписки",
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
"admin_user_total_paid_label": "💰 <b>Всего оплачено:</b>",
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",