diff --git a/README.md b/README.md index c624eca..47ef8d4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ ## Возможности +- **Вход через FunPay** (`golden_key`) — GUI / APK / `funpaybot login` +- **Вечный онлайн** + авто-промо в фоне (APK foreground service) +- **Продвижение**: поднятие, андеркат, −/+5%, SEO-заголовки, активация лотов, отчёт конкурентов +- **Автоответы / автовыдача / приветствия / отзывы / рассылка** +- **Веб-панель** с вкладками: дашборд, промо, лоты, заказы, чаты, склад + +## Возможности (таблица) + | Модуль | Описание | |--------|----------| | **Автоподнятие лотов** | Циклический `POST /lots/raise` по категориям из конфига | diff --git a/android/app/main.py b/android/app/main.py index 874e801..ac9fe51 100644 --- a/android/app/main.py +++ b/android/app/main.py @@ -1,88 +1,524 @@ -"""Android-лаунчер FunPayBot (Kivy).""" +"""Android UI FunPayBot — аккуратный интерфейс + вход без pydantic.""" + +from __future__ import annotations + +import threading +from pathlib import Path + from kivy.app import App +from kivy.clock import Clock +from kivy.core.window import Window +from kivy.graphics import Color, RoundedRectangle +from kivy.metrics import dp from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button +from kivy.uix.floatlayout import FloatLayout from kivy.uix.label import Label +from kivy.uix.scrollview import ScrollView +from kivy.uix.screenmanager import FadeTransition, Screen, ScreenManager from kivy.uix.textinput import TextInput -from kivy.clock import Clock -import threading -import os -from pathlib import Path +from kivy.utils import get_color_from_hex + +# FunPay-inspired: тёмный фон + оранжевый акцент (не purple / не cream-AI look) +BG = get_color_from_hex("#121212") +CARD = get_color_from_hex("#1C1C1E") +CARD2 = get_color_from_hex("#252528") +ORANGE = get_color_from_hex("#FF8036") +ORANGE_DIM = get_color_from_hex("#E06B28") +GREEN = get_color_from_hex("#34C759") +TEXT = get_color_from_hex("#F5F5F7") +MUTED = get_color_from_hex("#A1A1AA") +LINE = get_color_from_hex("#2C2C2E") +DANGER = get_color_from_hex("#FF453A") + +Window.clearcolor = BG + +def ui(cb): + Clock.schedule_once(lambda dt: cb(), 0) -class Root(BoxLayout): + +class Card(BoxLayout): def __init__(self, **kwargs): - super().__init__(orientation="vertical", padding=16, spacing=10, **kwargs) - self.status = Label(text="FunPayBot для Android", size_hint_y=None, height=40) - self.log = Label(text="Вставьте golden_key и нажмите Старт", size_hint_y=0.4) - self.key = TextInput(hint_text="golden_key", password=True, multiline=False, size_hint_y=None, height=44) - self.add_widget(self.status) - self.add_widget(self.key) - btn = Button(text="▶ Старт (вечный онлайн + бот)", size_hint_y=None, height=48) - btn.bind(on_press=self.start_bot) - self.add_widget(btn) - btn2 = Button(text="Только вечный онлайн", size_hint_y=None, height=48) - btn2.bind(on_press=self.start_online) - self.add_widget(btn2) - self.add_widget(self.log) - self._bot = None - - def _write_env(self): - key = (self.key.text or "").strip() - if not key: - self.log.text = "Нужен golden_key" - return False - Path(".env").write_text(f"FUNPAY_GOLDEN_KEY={key}\n", encoding="utf-8") - return True + radius = kwargs.pop("radius", dp(18)) + bg = kwargs.pop("bg", CARD) + super().__init__(**kwargs) + self._bg = bg + self._radius = radius + with self.canvas.before: + Color(*self._bg) + self._rect = RoundedRectangle(pos=self.pos, size=self.size, radius=[self._radius]) + self.bind(pos=self._sync, size=self._sync) - def start_bot(self, *_): - if not self._write_env(): - return - self.log.text = "Запуск..." - threading.Thread(target=self._run_all, daemon=True).start() + def _sync(self, *_): + self._rect.pos = self.pos + self._rect.size = self.size - def start_online(self, *_): - if not self._write_env(): - return - self.log.text = "Запуск вечного онлайна..." - threading.Thread(target=self._run_online, daemon=True).start() - def _run_all(self): +class AccentButton(Button): + def __init__(self, kind: str = "primary", **kwargs): + super().__init__(**kwargs) + self.background_normal = "" + self.background_down = "" + self.bold = True + self.color = (1, 1, 1, 1) + if kind == "primary": + self.background_color = ORANGE + elif kind == "success": + self.background_color = GREEN + self.color = (0.05, 0.12, 0.08, 1) + elif kind == "ghost": + self.background_color = CARD2 + elif kind == "danger": + self.background_color = DANGER + self.size_hint_y = None + self.height = kwargs.get("height", dp(50)) + self.font_size = "16sp" + + +class SoftInput(TextInput): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.background_color = CARD2 + self.foreground_color = TEXT + self.cursor_color = ORANGE + self.hint_text_color = MUTED + self.padding = [dp(14), dp(12), dp(14), dp(12)] + self.size_hint_y = None + self.height = kwargs.get("height", dp(48)) + self.multiline = kwargs.get("multiline", False) + + +def _foreground(title: str = "FunPayBot", text: str = "Работает в фоне") -> None: + try: + from jnius import autoclass + + PythonActivity = autoclass("org.kivy.android.PythonActivity") + Context = autoclass("android.content.Context") + NotificationBuilder = autoclass("android.app.Notification$Builder") + NotificationChannel = autoclass("android.app.NotificationChannel") + NotificationManager = autoclass("android.app.NotificationManager") + String = autoclass("java.lang.String") + activity = PythonActivity.mActivity + nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) + channel_id = "funpaybot_fg" try: - from funpaybot.config import load_config - from funpaybot.bot import FunPayBot - from funpaybot.utils.logging_setup import setup_logging - cfg = load_config() - cfg.online.enabled = True - setup_logging(cfg.logging) - bot = FunPayBot(cfg) - bot.start() - self._bot = bot - Clock.schedule_once(lambda dt: setattr(self.log, "text", f"Онлайн: {bot.online.status_text}"), 0) - except Exception as exc: - Clock.schedule_once(lambda dt: setattr(self.log, "text", f"Ошибка: {exc}"), 0) - - def _run_online(self): + channel = NotificationChannel(channel_id, String("FunPayBot"), NotificationManager.IMPORTANCE_LOW) + nm.createNotificationChannel(channel) + builder = NotificationBuilder(activity, channel_id) + except Exception: + builder = NotificationBuilder(activity) + builder.setContentTitle(String(title)) + builder.setContentText(String(text)) + builder.setSmallIcon(activity.getApplicationInfo().icon) + builder.setOngoing(True) + notification = builder.build() + if hasattr(activity, "startForeground"): + activity.startForeground(19001, notification) + nm.notify(19001, notification) + except Exception: + pass + + +class LoginScreen(Screen): + def __init__(self, app_ref, **kwargs): + super().__init__(**kwargs) + self.app_ref = app_ref + root = FloatLayout() + with root.canvas.before: + Color(*BG) + self._bg = RoundedRectangle(pos=root.pos, size=root.size, radius=[0]) + root.bind(pos=lambda *_: setattr(self._bg, "pos", root.pos), size=lambda *_: setattr(self._bg, "size", root.size)) + + col = BoxLayout(orientation="vertical", padding=[dp(22), dp(28)], spacing=dp(12), size_hint=(1, 1)) + + brand = BoxLayout(orientation="vertical", size_hint_y=None, height=dp(120), spacing=dp(4)) + brand.add_widget(Label(text="FUNPAY", color=ORANGE, bold=True, font_size="14sp", size_hint_y=None, height=dp(22))) + brand.add_widget(Label(text="FunPayBot", color=TEXT, bold=True, font_size="34sp", size_hint_y=None, height=dp(44))) + brand.add_widget( + Label( + text="Вход продавца · онлайн · продвижение лотов", + color=MUTED, + font_size="13sp", + size_hint_y=None, + height=dp(24), + ) + ) + col.add_widget(brand) + + card = Card(orientation="vertical", padding=dp(18), spacing=dp(10), size_hint_y=None, height=dp(310)) + card.add_widget(Label(text="Войти через FunPay", color=TEXT, bold=True, font_size="18sp", size_hint_y=None, height=dp(28))) + tip = Label( + text="1) funpay.com → F12 → Cookies\n2) Скопируйте golden_key\n3) Вставьте ниже", + color=MUTED, + font_size="13sp", + halign="left", + valign="middle", + size_hint_y=None, + height=dp(64), + ) + tip.bind(size=lambda *_: setattr(tip, "text_size", (tip.width, None))) + card.add_widget(tip) + + card.add_widget(Label(text="golden_key", color=MUTED, font_size="12sp", size_hint_y=None, height=dp(18), halign="left")) + self.key = SoftInput(password=True, hint_text="вставьте ключ сессии") + card.add_widget(self.key) + self.err = Label(text="", color=DANGER, font_size="13sp", size_hint_y=None, height=dp(36), halign="left", valign="middle") + self.err.bind(size=lambda *_: setattr(self.err, "text_size", (self.err.width, None))) + card.add_widget(self.err) + + b1 = AccentButton(text="Войти в FunPay", kind="primary") + b1.bind(on_press=lambda *_: self.app_ref.do_login(False)) + b2 = AccentButton(text="Войти и работать в фоне", kind="success") + b2.bind(on_press=lambda *_: self.app_ref.do_login(True)) + card.add_widget(b1) + card.add_widget(b2) + col.add_widget(card) + col.add_widget(Label()) # spacer + root.add_widget(col) + self.add_widget(root) + Clock.schedule_once(self._preload, 0) + + def _preload(self, *_): try: - from funpaybot.config import load_config - from funpaybot.bot import FunPayBot - from funpaybot.utils.logging_setup import setup_logging - cfg = load_config() - cfg.online.enabled = True - setup_logging(cfg.logging) - bot = FunPayBot(cfg) - bot.client.warmup() - bot.online.start() - self._bot = bot - Clock.schedule_once(lambda dt: setattr(self.log, "text", "Вечный онлайн активен"), 0) - except Exception as e: - Clock.schedule_once(lambda dt, err=e: setattr(self.log, "text", f"Ошибка: {err}"), 0) + env = Path(".env") + if env.exists(): + for line in env.read_text(encoding="utf-8").splitlines(): + if line.startswith("FUNPAY_GOLDEN_KEY="): + self.key.text = line.split("=", 1)[1].strip() + break + except Exception: + pass + + +class HomeScreen(Screen): + def __init__(self, app_ref, **kwargs): + super().__init__(**kwargs) + self.app_ref = app_ref + root = BoxLayout(orientation="vertical", padding=dp(16), spacing=dp(10)) + + head = BoxLayout(size_hint_y=None, height=dp(56)) + head.add_widget(Label(text="FunPayBot", color=TEXT, bold=True, font_size="22sp", halign="left")) + self.badge = Label(text="offline", color=ORANGE, bold=True, font_size="13sp", size_hint_x=0.35) + head.add_widget(self.badge) + root.add_widget(head) + + self.user_card = Card(orientation="vertical", padding=dp(16), spacing=dp(4), size_hint_y=None, height=dp(88)) + self.user_name = Label(text="—", color=TEXT, bold=True, font_size="18sp", size_hint_y=None, height=dp(28), halign="left") + self.user_meta = Label(text="", color=MUTED, font_size="13sp", size_hint_y=None, height=dp(22), halign="left") + for w in (self.user_name, self.user_meta): + w.bind(size=lambda inst, *_: setattr(inst, "text_size", (inst.width, None))) + self.user_card.add_widget(w) + root.add_widget(self.user_card) + + stats = BoxLayout(size_hint_y=None, height=dp(78), spacing=dp(8)) + self.s_online = self._stat("Онлайн", "—") + self.s_lots = self._stat("Лоты", "—") + self.s_sales = self._stat("Заказы", "—") + stats.add_widget(self.s_online) + stats.add_widget(self.s_lots) + stats.add_widget(self.s_sales) + root.add_widget(stats) + + actions = ScrollView(size_hint=(1, 1)) + col = BoxLayout(orientation="vertical", spacing=dp(8), size_hint_y=None, padding=[0, dp(4), 0, dp(20)]) + col.bind(minimum_height=col.setter("height")) + for text, handler, kind in [ + ("▶ Запустить всё в фоне", self.app_ref.start_all, "success"), + ("⬆ Поднять лоты", self.app_ref.raise_lots, "primary"), + ("🚀 Полный промо-цикл", self.app_ref.promo_full, "primary"), + ("🟢 Ping онлайн", self.app_ref.ping, "ghost"), + ("💸 Андеркат цен", self.app_ref.undercut, "ghost"), + ("✅ Активировать лоты", self.app_ref.activate_all, "ghost"), + ("📊 Мои лоты", self.app_ref.show_lots, "ghost"), + ("📈 Заказы", self.app_ref.show_orders, "ghost"), + ("💬 Чаты", self.app_ref.show_chats, "ghost"), + ("⏹ Стоп", self.app_ref.stop_all, "danger"), + ]: + b = AccentButton(text=text, kind=kind) + b.bind(on_press=handler) + col.add_widget(b) + + row = BoxLayout(size_hint_y=None, height=dp(48), spacing=dp(8)) + self.node = SoftInput(hint_text="node_id", height=dp(48)) + self.game = SoftInput(hint_text="game_id", height=dp(48)) + row.add_widget(self.node) + row.add_widget(self.game) + col.add_widget(row) + + self.log = Label(text="Готово", color=MUTED, font_size="12sp", size_hint_y=None, halign="left", valign="top") + self.log.bind(texture_size=lambda *_: setattr(self.log, "height", max(dp(80), self.log.texture_size[1] + 8))) + self.log.bind(size=lambda *_: setattr(self.log, "text_size", (self.log.width, None))) + log_card = Card(orientation="vertical", padding=dp(12), size_hint_y=None, height=dp(120)) + log_card.bind(height=lambda *_: None) + log_card.add_widget(Label(text="Журнал", color=MUTED, font_size="12sp", size_hint_y=None, height=dp(18))) + log_card.add_widget(self.log) + col.add_widget(log_card) + + actions.add_widget(col) + root.add_widget(actions) + self.add_widget(root) + + def _stat(self, title, value): + c = Card(orientation="vertical", padding=dp(10), spacing=dp(2)) + c.add_widget(Label(text=title, color=MUTED, font_size="11sp", size_hint_y=None, height=dp(16))) + lab = Label(text=value, color=TEXT, bold=True, font_size="16sp", size_hint_y=None, height=dp(28)) + c.add_widget(lab) + c.value_label = lab + return c + + def set_user(self, name: str, meta: str): + self.user_name.text = name + self.user_meta.text = meta + + def set_stats(self, online: str, lots: str, sales: str): + self.s_online.value_label.text = online + self.s_lots.value_label.text = lots + self.s_sales.value_label.text = sales + self.badge.text = "online" if "онлайн" in online.lower() or online not in {"—", "стоп"} else "idle" + self.badge.color = GREEN if self.badge.text == "online" else ORANGE class FunPayBotApp(App): def build(self): self.title = "FunPayBot" - return Root() + self.bot = None + self.client = None + self.promo = None + self.sm = ScreenManager(transition=FadeTransition(duration=0.15)) + self.login = LoginScreen(self, name="login") + self.home = HomeScreen(self, name="home") + self.sm.add_widget(self.login) + self.sm.add_widget(self.home) + return self.sm + + def append(self, msg: str): + def upd(): + self.home.log.text = f"{msg}\n{self.home.log.text}"[:3500] + if self.sm.current == "login": + self.login.err.text = msg + + ui(upd) + + def do_login(self, start: bool): + key = (self.login.key.text or "").strip() + if not key: + self.login.err.text = "Вставьте golden_key" + return + self.login.err.text = "Вход…" + self.login.err.color = MUTED + + def work(): + try: + from funpaybot.auth import login_with_golden_key + + client, user = login_with_golden_key(key) + self.client = client + + def ok(): + self.login.err.color = DANGER + self.home.set_user(user.username or "Продавец", f"ID {user.id}") + self.home.set_stats("сессия", "—", "—") + self.sm.current = "home" + self.append(f"Вход OK: {user.username}") + + ui(ok) + if start: + self._start_bot_bg() + except Exception as exc: + def fail(): + self.login.err.color = DANGER + self.login.err.text = f"Ошибка входа: {exc}" + + ui(fail) + + threading.Thread(target=work, daemon=True).start() + + def _start_bot_bg(self): + if self.bot: + self.append("Уже запущено") + return + self.append("Запуск в фоне…") + _foreground("FunPayBot", "Онлайн · промо · автовыдача") + + def work(): + try: + from funpaybot.bot import FunPayBot + from funpaybot.config import load_config + from funpaybot.modules.promo import PromoEngine + from funpaybot.utils.logging_setup import setup_logging + + cfg = load_config() + cfg.online.enabled = True + setup_logging(cfg.logging) + bot = FunPayBot(cfg) + bot.start() + self.bot = bot + self.client = bot.client + self.promo = PromoEngine(bot.client, cfg.raise_lots) + node = None + if cfg.raise_lots.categories and cfg.raise_lots.categories[0].node_id > 0: + node = cfg.raise_lots.categories[0].node_id + self.promo.start_auto_promo(node_id=node) + self.append("Фон активен: онлайн + промо + события") + ui(lambda: self.home.set_stats(bot.online.status_text[:18], "…", "…")) + self._refresh_stats() + except Exception as exc: + self.append(f"Ошибка запуска: {exc}") + + threading.Thread(target=work, daemon=True).start() + + def start_all(self, *_): + if not self.client: + self.sm.current = "login" + self.append("Сначала войдите") + return + self._start_bot_bg() + + def stop_all(self, *_): + def work(): + try: + if self.promo: + self.promo.stop_auto_promo() + if self.bot: + self.bot.stop() + self.bot = None + self.append("Остановлено") + ui(lambda: self.home.set_stats("стоп", "—", "—")) + except Exception as exc: + self.append(str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _need_promo(self): + if self.promo: + return self.promo + if not self.client: + self.append("Нет сессии") + return None + from funpaybot.config import load_config + from funpaybot.modules.promo import PromoEngine + + self.promo = PromoEngine(self.client, load_config().raise_lots) + return self.promo + + def _node_game(self): + try: + node = int(self.home.node.text or 0) or None + except ValueError: + node = None + try: + game = int(self.home.game.text or 0) or None + except ValueError: + game = None + return node, game + + def ping(self, *_): + def work(): + try: + if self.bot: + self.bot.online.ping_once() + self.append(self.bot.online.status_text) + ui(lambda: self.home.set_stats(self.bot.online.status_text[:18], self.home.s_lots.value_label.text, self.home.s_sales.value_label.text)) + elif self.client: + self.client.keep_online() + self.append("runner ping OK") + else: + self.append("нет сессии") + except Exception as e: + self.append(str(e)) + + threading.Thread(target=work, daemon=True).start() + + def raise_lots(self, *_): + def work(): + p = self._need_promo() + if p: + self.append(str(p.raise_all_configured())[:500]) + + threading.Thread(target=work, daemon=True).start() + + def promo_full(self, *_): + def work(): + p = self._need_promo() + if not p: + return + node, game = self._node_game() + r = p.full_promo_cycle(node_id=node, game_id=game) + self.append(f"Промо: raised={r.raised} prices={r.price_changed} act={r.activated}") + + threading.Thread(target=work, daemon=True).start() + + def undercut(self, *_): + def work(): + p = self._need_promo() + node, _ = self._node_game() + if not p or not node: + self.append("Укажите node_id") + return + self.append(str(p.undercut_node(node))[:500]) + + threading.Thread(target=work, daemon=True).start() + + def activate_all(self, *_): + def work(): + p = self._need_promo() + if p: + self.append(f"Активировано: {p.activate_all()}") + + threading.Thread(target=work, daemon=True).start() + + def show_lots(self, *_): + def work(): + if not self.client: + return + from funpaybot.modules.stats import StatsService + + s = StatsService(self.client).lots_summary() + self.append(f"Лоты: {s['active']}/{s['total']} акт., avg {s['avg_price']}") + ui(lambda: self.home.set_stats(self.home.s_online.value_label.text, f"{s['active']}/{s['total']}", self.home.s_sales.value_label.text)) + + threading.Thread(target=work, daemon=True).start() + + def show_orders(self, *_): + def work(): + if not self.client: + return + from funpaybot.modules.stats import StatsService + + s = StatsService(self.client).sales_summary() + self.append(f"Заказы: {s['orders_count']}, сумма {s['revenue_sum']}") + ui(lambda: self.home.set_stats(self.home.s_online.value_label.text, self.home.s_lots.value_label.text, str(s["orders_count"]))) + + threading.Thread(target=work, daemon=True).start() + + def show_chats(self, *_): + def work(): + if not self.client: + return + chats = self.client.get_chat_bookmarks()[:15] + self.append(str([{"id": c.id, "name": c.name} for c in chats])) + + threading.Thread(target=work, daemon=True).start() + + def _refresh_stats(self): + def work(): + try: + if not self.client: + return + from funpaybot.modules.stats import StatsService + + lots = StatsService(self.client).lots_summary() + sales = StatsService(self.client).sales_summary() + online = self.bot.online.status_text[:18] if self.bot else "сессия" + ui(lambda: self.home.set_stats(online, f"{lots['active']}/{lots['total']}", str(sales["orders_count"]))) + except Exception: + pass + + threading.Thread(target=work, daemon=True).start() + + def on_pause(self): + return True if __name__ == "__main__": diff --git a/android/app/requirements.txt b/android/app/requirements.txt index 85bd533..bc11906 100644 --- a/android/app/requirements.txt +++ b/android/app/requirements.txt @@ -1,12 +1,4 @@ requests>=2.31.0 beautifulsoup4>=4.12.0 -lxml>=5.0.0 PyYAML>=6.0.1 -rich>=13.7.0 -typer>=0.12.0 python-dotenv>=1.0.0 -colorama>=0.4.6 -schedule>=1.2.1 -pydantic>=2.5.0 -pydantic-settings>=2.1.0 -flask>=3.0.0 diff --git a/android/app/service.py b/android/app/service.py new file mode 100644 index 0000000..e28e0f0 --- /dev/null +++ b/android/app/service.py @@ -0,0 +1,56 @@ +"""Фоновый Android-сервис FunPayBot (buildozer services).""" + +from __future__ import annotations + +# Этот файл запускается отдельно от UI, когда приложение уходит в фон / сервис стартует. + + +def main() -> None: + import time + from pathlib import Path + + # минимальный keepalive + бот + try: + from funpaybot.bot import FunPayBot + from funpaybot.config import load_config + from funpaybot.modules.promo import PromoEngine + from funpaybot.utils.logging_setup import setup_logging + except Exception: + # если пакет ещё не в path + import sys + + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from funpaybot.bot import FunPayBot + from funpaybot.config import load_config + from funpaybot.modules.promo import PromoEngine + from funpaybot.utils.logging_setup import setup_logging + + cfg = load_config() + if not cfg.golden_key or cfg.golden_key == "your_golden_key_here": + # ждём пока UI сохранит ключ + while True: + cfg = load_config() + if cfg.golden_key and cfg.golden_key != "your_golden_key_here": + break + time.sleep(5) + + cfg.online.enabled = True + setup_logging(cfg.logging) + bot = FunPayBot(cfg) + bot.start() + promo = PromoEngine(bot.client, cfg.raise_lots) + node = None + if cfg.raise_lots.categories and cfg.raise_lots.categories[0].node_id > 0: + node = cfg.raise_lots.categories[0].node_id + promo.start_auto_promo(node_id=node) + + while True: + try: + bot.online.ping_once() + except Exception: + pass + time.sleep(30) + + +if __name__ == "__main__": + main() diff --git a/android/buildozer.spec b/android/buildozer.spec index 4188092..deb75fc 100644 --- a/android/buildozer.spec +++ b/android/buildozer.spec @@ -4,16 +4,17 @@ package.name = funpaybot package.domain = com.funpaybot source.dir = app source.include_exts = py,png,jpg,kv,atlas,yaml,txt,md -version = 1.0.0 -requirements = python3,kivy,requests,beautifulsoup4,PyYAML,python-dotenv,pydantic,pydantic-settings,certifi,charset-normalizer,idna,urllib3,soupsieve,typing-extensions,annotated-types,pyjnius,android +version = 2.1.0 +requirements = python3,kivy,requests,beautifulsoup4,PyYAML,python-dotenv,certifi,charset-normalizer,idna,urllib3,soupsieve,pyjnius,android orientation = portrait fullscreen = 0 -android.permissions = INTERNET,ACCESS_NETWORK_STATE,WRITE_EXTERNAL_STORAGE,READ_EXTERNAL_STORAGE +android.permissions = INTERNET,ACCESS_NETWORK_STATE,WRITE_EXTERNAL_STORAGE,READ_EXTERNAL_STORAGE,FOREGROUND_SERVICE,WAKE_LOCK,RECEIVE_BOOT_COMPLETED,POST_NOTIFICATIONS android.api = 33 android.minapi = 24 android.ndk = 25b android.accept_sdk_license = True android.archs = arm64-v8a +services = FunpayService:service.py p4a.branch = master [buildozer] diff --git a/funpaybot/__init__.py b/funpaybot/__init__.py index 60080b3..37173de 100644 --- a/funpaybot/__init__.py +++ b/funpaybot/__init__.py @@ -1,4 +1,4 @@ """FunPayBot — многофункциональная автоматизация для продавцов FunPay.""" -__version__ = "1.0.0" +__version__ = "2.1.0" __all__ = ["__version__"] diff --git a/funpaybot/auth.py b/funpaybot/auth.py new file mode 100644 index 0000000..8d08981 --- /dev/null +++ b/funpaybot/auth.py @@ -0,0 +1,75 @@ +"""Авторизация FunPay по golden_key (+ сохранение сессии).""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from funpaybot.client import AuthError, FunPayClient, FunPayError +from funpaybot.models import UserInfo + +logger = logging.getLogger(__name__) + + +def save_golden_key(key: str, env_path: str | Path = ".env") -> None: + path = Path(env_path) + key = key.strip() + lines: list[str] = [] + if path.exists(): + lines = path.read_text(encoding="utf-8").splitlines() + found = False + for i, line in enumerate(lines): + if line.startswith("FUNPAY_GOLDEN_KEY="): + lines[i] = f"FUNPAY_GOLDEN_KEY={key}" + found = True + break + if not found: + lines.append(f"FUNPAY_GOLDEN_KEY={key}") + else: + lines = [ + f"FUNPAY_GOLDEN_KEY={key}", + "FUNPAY_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def login_with_golden_key( + golden_key: str, + *, + user_agent: str | None = None, + proxy: dict[str, str] | None = None, + save: bool = True, +) -> tuple[FunPayClient, UserInfo]: + """ + Войти в FunPay по cookie golden_key. + Как получить: браузер → FunPay → F12 → Application → Cookies → golden_key. + """ + key = (golden_key or "").strip() + if not key or key == "your_golden_key_here": + raise AuthError("Вставьте golden_key из cookie FunPay") + + if save: + save_golden_key(key) + + client = FunPayClient(golden_key=key, user_agent=user_agent, proxy=proxy) + try: + user = client.warmup() + except FunPayError: + client.close() + raise + logger.info("Вход выполнен: %s (id=%s)", user.username, user.id) + return client, user + + +def login_from_config(config_path: str | None = None): + from funpaybot.config import load_config + + cfg = load_config(config_path) + client, user = login_with_golden_key( + cfg.golden_key, + user_agent=cfg.user_agent, + proxy=cfg.proxy_dict(), + save=False, + ) + return cfg, client, user diff --git a/funpaybot/bot.py b/funpaybot/bot.py index cd1e2fb..0de9644 100644 --- a/funpaybot/bot.py +++ b/funpaybot/bot.py @@ -17,6 +17,7 @@ Greeter, LotRaiser, PriceManager, + PromoEngine, ReviewReminder, ) from funpaybot.models import ChatMessage, Order @@ -43,6 +44,7 @@ def __init__(self, config: AppConfig) -> None: self.greeter = Greeter(self.client, config.greeting) self.reviews = ReviewReminder(self.client, config.review_reminder) self.pricing = PriceManager(self.client, config.pricing) + self.promo = PromoEngine(self.client, config.raise_lots) self.events = EventListener(self.client, poll_interval=8.0) self._stop = threading.Event() @@ -85,10 +87,12 @@ def start(self) -> None: node_ids = [c.node_id for c in self.config.raise_lots.categories if c.node_id > 0] self.pricing.start(node_ids) + # авто-промо (поднятие + активация + опционально undercut) + self.promo.start_auto_promo(node_id=node_ids[0] if node_ids else None) self.events.start() logger.info( - "Все модули запущены. Вечный онлайн: %s. Ctrl+C для остановки.", + "Все модули запущены. Вечный онлайн: %s. Авто-промо: ON. Ctrl+C для остановки.", self.online.status_text, ) @@ -109,6 +113,7 @@ def stop(self) -> None: self.online.stop() self.reviews.stop() self.pricing.stop() + self.promo.stop_auto_promo() self.client.close() logger.info("Остановлен.") diff --git a/funpaybot/cli.py b/funpaybot/cli.py index 1aed159..b1ddc84 100644 --- a/funpaybot/cli.py +++ b/funpaybot/cli.py @@ -415,6 +415,67 @@ def online_cmd( bot.client.close() +@app.command("login") +def login_cmd( + key: Optional[str] = typer.Option(None, "--key", "-k", help="golden_key (иначе спросит)"), + config: Optional[str] = typer.Option(None, "--config", "-c"), +) -> None: + """Войти в FunPay по golden_key и сохранить сессию.""" + from funpaybot.auth import login_with_golden_key + + setup_logging(load_config(config).logging) + token = key or console.input("[bold]golden_key:[/] ").strip() + try: + client, user = login_with_golden_key(token) + bal = client.get_balance() + console.print(f"[green]Вход OK[/]: {user.username} (id={user.id})") + console.print(bal) + client.close() + except Exception as exc: + console.print(f"[red]{exc}[/]") + raise typer.Exit(1) from exc + + +@app.command("promo") +def promo_cmd( + action: str = typer.Argument("full", help="full|raise|undercut|activate|titles|competitors"), + node_id: Optional[int] = typer.Option(None, "--node-id", "-n"), + game_id: Optional[int] = typer.Option(None, "--game-id", "-g"), + config: Optional[str] = typer.Option(None, "--config", "-c"), +) -> None: + """Продвижение товаров (поднятие, цены, активация, SEO).""" + from funpaybot.modules.promo import PromoEngine + + cfg, client = _make_client(config) + try: + client.warmup() + promo = PromoEngine(client, cfg.raise_lots) + if action == "full": + r = promo.full_promo_cycle(node_id=node_id, game_id=game_id) + console.print(r) + elif action == "raise": + console.print(promo.raise_all_configured()) + elif action == "undercut": + if not node_id: + console.print("[red]нужен --node-id[/]") + raise typer.Exit(2) + console.print(promo.undercut_node(node_id)) + elif action == "activate": + console.print({"activated": promo.activate_all()}) + elif action == "titles": + console.print(promo.boost_titles(dry_run=True)) + elif action == "competitors": + if not node_id: + console.print("[red]нужен --node-id[/]") + raise typer.Exit(2) + console.print(promo.competitor_report(node_id)) + else: + console.print("[red]unknown action[/]") + raise typer.Exit(2) + finally: + client.close() + + @app.command("gui") def gui_cmd( config: Optional[str] = typer.Option(None, "--config", "-c"), diff --git a/funpaybot/config.py b/funpaybot/config.py index 846784f..fc55602 100644 --- a/funpaybot/config.py +++ b/funpaybot/config.py @@ -1,90 +1,104 @@ -"""Загрузка конфигурации FunPayBot.""" +"""Загрузка конфигурации FunPayBot (без pydantic — работает в APK).""" from __future__ import annotations import os +from dataclasses import dataclass, field, fields from pathlib import Path from typing import Any import yaml from dotenv import load_dotenv -from pydantic import BaseModel, Field -class RaiseCategory(BaseModel): - game_id: int - node_id: int +@dataclass +class RaiseCategory: + game_id: int = 0 + node_id: int = 0 name: str = "" -class RaiseLotsConfig(BaseModel): +@dataclass +class RaiseLotsConfig: enabled: bool = True interval_minutes: int = 65 - categories: list[RaiseCategory] = Field(default_factory=list) + categories: list[RaiseCategory] = field(default_factory=list) -class OnlineConfig(BaseModel): +@dataclass +class OnlineConfig: enabled: bool = True - ping_interval_seconds: int = 60 + ping_interval_seconds: int = 45 -class AutoresponderRule(BaseModel): - keywords: list[str] - reply: str +@dataclass +class AutoresponderRule: + keywords: list[str] = field(default_factory=list) + reply: str = "" -class AutoresponderConfig(BaseModel): +@dataclass +class AutoresponderConfig: enabled: bool = True ignore_own_messages: bool = True - rules: list[AutoresponderRule] = Field(default_factory=list) + rules: list[AutoresponderRule] = field(default_factory=list) -class GreetingConfig(BaseModel): +@dataclass +class GreetingConfig: enabled: bool = True message: str = "Спасибо за заказ, {buyer}!" -class DeliveryMapping(BaseModel): - match: str - file: str +@dataclass +class DeliveryMapping: + match: str = "" + file: str = "" message: str = "{product}" -class DeliveryConfig(BaseModel): +@dataclass +class DeliveryConfig: enabled: bool = True stock_dir: str = "data/products" - mappings: list[DeliveryMapping] = Field(default_factory=list) + mappings: list[DeliveryMapping] = field(default_factory=list) -class ReviewReminderConfig(BaseModel): +@dataclass +class ReviewReminderConfig: enabled: bool = True delay_minutes: int = 30 message: str = "{buyer}, буду рад отзыву!" -class OrdersConfig(BaseModel): +@dataclass +class OrdersConfig: log_new: bool = True auto_confirm_hint: bool = False -class UndercutConfig(BaseModel): +@dataclass +class UndercutConfig: enabled: bool = False offset: float = 0.01 min_price: float = 1.0 check_interval_minutes: int = 30 -class PricingConfig(BaseModel): - undercut: UndercutConfig = Field(default_factory=UndercutConfig) +@dataclass +class PricingConfig: + undercut: UndercutConfig = field(default_factory=UndercutConfig) -class LoggingConfig(BaseModel): +@dataclass +class LoggingConfig: level: str = "INFO" file: str = "data/logs/bot.log" console: bool = True -class AppConfig(BaseModel): +@dataclass +class AppConfig: golden_key: str = "" user_agent: str = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " @@ -93,15 +107,15 @@ class AppConfig(BaseModel): proxy: str | None = None requests_timeout: int = 20 locale: str = "ru" - raise_lots: RaiseLotsConfig = Field(default_factory=RaiseLotsConfig) - online: OnlineConfig = Field(default_factory=OnlineConfig) - autoresponder: AutoresponderConfig = Field(default_factory=AutoresponderConfig) - greeting: GreetingConfig = Field(default_factory=GreetingConfig) - delivery: DeliveryConfig = Field(default_factory=DeliveryConfig) - review_reminder: ReviewReminderConfig = Field(default_factory=ReviewReminderConfig) - orders: OrdersConfig = Field(default_factory=OrdersConfig) - pricing: PricingConfig = Field(default_factory=PricingConfig) - logging: LoggingConfig = Field(default_factory=LoggingConfig) + raise_lots: RaiseLotsConfig = field(default_factory=RaiseLotsConfig) + online: OnlineConfig = field(default_factory=OnlineConfig) + autoresponder: AutoresponderConfig = field(default_factory=AutoresponderConfig) + greeting: GreetingConfig = field(default_factory=GreetingConfig) + delivery: DeliveryConfig = field(default_factory=DeliveryConfig) + review_reminder: ReviewReminderConfig = field(default_factory=ReviewReminderConfig) + orders: OrdersConfig = field(default_factory=OrdersConfig) + pricing: PricingConfig = field(default_factory=PricingConfig) + logging: LoggingConfig = field(default_factory=LoggingConfig) def proxy_dict(self) -> dict[str, str] | None: if not self.proxy: @@ -119,6 +133,51 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any return result +def _from_dict(cls, data: Any): + if not isinstance(data, dict): + return cls() + kwargs: dict[str, Any] = {} + for f in fields(cls): + if f.name not in data: + continue + val = data[f.name] + if f.name == "categories" and cls is RaiseLotsConfig: + kwargs[f.name] = [ + RaiseCategory(**x) if isinstance(x, dict) else x for x in (val or []) + ] + elif f.name == "rules" and cls is AutoresponderConfig: + kwargs[f.name] = [ + AutoresponderRule(**x) if isinstance(x, dict) else x for x in (val or []) + ] + elif f.name == "mappings" and cls is DeliveryConfig: + kwargs[f.name] = [ + DeliveryMapping(**x) if isinstance(x, dict) else x for x in (val or []) + ] + elif f.name == "undercut" and cls is PricingConfig: + kwargs[f.name] = UndercutConfig(**val) if isinstance(val, dict) else val + elif f.name == "raise_lots": + kwargs[f.name] = _from_dict(RaiseLotsConfig, val) + elif f.name == "online": + kwargs[f.name] = _from_dict(OnlineConfig, val) + elif f.name == "autoresponder": + kwargs[f.name] = _from_dict(AutoresponderConfig, val) + elif f.name == "greeting": + kwargs[f.name] = _from_dict(GreetingConfig, val) + elif f.name == "delivery": + kwargs[f.name] = _from_dict(DeliveryConfig, val) + elif f.name == "review_reminder": + kwargs[f.name] = _from_dict(ReviewReminderConfig, val) + elif f.name == "orders": + kwargs[f.name] = _from_dict(OrdersConfig, val) + elif f.name == "pricing": + kwargs[f.name] = _from_dict(PricingConfig, val) + elif f.name == "logging": + kwargs[f.name] = _from_dict(LoggingConfig, val) + else: + kwargs[f.name] = val + return cls(**kwargs) + + def load_config(path: str | Path | None = None) -> AppConfig: """Загрузить конфиг из YAML + переменных окружения.""" load_dotenv() @@ -155,4 +214,4 @@ def load_config(path: str | Path | None = None) -> AppConfig: if env_proxy: data["proxy"] = env_proxy - return AppConfig.model_validate(data) + return _from_dict(AppConfig, data) diff --git a/funpaybot/gui.py b/funpaybot/gui.py index 244f70c..e8f0771 100644 --- a/funpaybot/gui.py +++ b/funpaybot/gui.py @@ -1,4 +1,4 @@ -"""Локальная веб-панель управления FunPayBot.""" +"""Современная веб-панель FunPayBot: логин + полный функционал.""" from __future__ import annotations @@ -10,168 +10,260 @@ from flask import Flask, jsonify, render_template_string, request from funpaybot import __version__ +from funpaybot.auth import login_with_golden_key, save_golden_key from funpaybot.bot import FunPayBot +from funpaybot.client import AuthError, FunPayError from funpaybot.config import load_config +from funpaybot.modules.promo import PromoEngine +from funpaybot.modules.stats import BroadcastService, StatsService from funpaybot.utils.logging_setup import setup_logging +from funpaybot.utils.stock import ProductStock logger = logging.getLogger(__name__) -PANEL_HTML = """ +PANEL_HTML = r"""
- - -Вход через FunPay · вечный онлайн · продвижение · автовыдача · чаты · статистика
+ +1) Откройте funpay.com в браузере
2) F12 → Application → Cookies → скопируйте golden_key
3) Вставьте ниже и нажмите «Войти»
Панель продавца: вечный онлайн, поднятие лотов, автовыдача и автоответы
+Запуск включает: вечный онлайн, автоподнятие, автоответы, автовыдачу, приветствия, отзывы, авто-промо.
+ +