From 18b8247e2158a1663375c4fbd7338324a170d16b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 3 May 2026 02:10:53 +0000 Subject: [PATCH 1/2] Added and updated repositories 2026-05-03 02:10:53 --- SenkoGuardian/SenModules/ChatCopy.py | 621 +++++++++---- SenkoGuardian/SenModules/Gemini.py | 962 +++++++++++++++++---- fiksofficial/python-modules/createpacks.py | 179 ++-- mead0wsss/mead0wsMods/gifts.json | 6 + 4 files changed, 1348 insertions(+), 420 deletions(-) diff --git a/SenkoGuardian/SenModules/ChatCopy.py b/SenkoGuardian/SenModules/ChatCopy.py index 9cf0713..d132857 100644 --- a/SenkoGuardian/SenModules/ChatCopy.py +++ b/SenkoGuardian/SenModules/ChatCopy.py @@ -7,7 +7,7 @@ # meta banner: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png # meta pic: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png -__version__ = ("1", "3", "0") # в этот раз комменты свои добавил что бы было понятно кратко, что да как и где что работает. +__version__ = ("1", "5", "0") # в этот раз комменты свои добавил что бы было понятно кратко, что да как и где что работает. """ ̄へ ̄""" @@ -27,13 +27,16 @@ import re import traceback import random import time +import copy +import shlex from datetime import datetime, timedelta, timezone MSK = timezone(timedelta(hours=3), name="MSK") -from telethon import functions, errors, types +from telethon import functions, errors, types, utils as tl_utils from telethon.tl.types import Message, Channel from .. import loader, utils logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) _cc_client = None _cc_log_channel = None @@ -46,9 +49,12 @@ class _CCTopicHandler(logging.Handler): return try: text = f"[{record.levelname}] {self.format(record)}" + chat_id = int(_cc_log_channel) + if chat_id > 0: + chat_id = int(f"-100{chat_id}") asyncio.ensure_future( _cc_client.send_message( - int(f"-100{_cc_log_channel}"), + chat_id, text, parse_mode="html", reply_to=_cc_log_topic_id, @@ -57,10 +63,12 @@ class _CCTopicHandler(logging.Handler): except Exception: pass - _cc_topic_handler = _CCTopicHandler() _cc_topic_handler.setLevel(logging.INFO) # INFO чтобы видеть прогресс пересылки -logger.addHandler(_cc_topic_handler) +_cc_topic_handler.setFormatter(logging.Formatter("%(message)s")) +_cc_topic_handler._chatcopy_topic_handler = True +if not any(getattr(handler, "_chatcopy_topic_handler", False) for handler in logger.handlers): + logger.addHandler(_cc_topic_handler) FILTER_ALL = "all" FILTER_MEDIA = "media" @@ -77,6 +85,7 @@ class ChatCopy(loader.Module): "cfg_batch": "Размер пачки сообщений (1-100)", "cfg_delay": "Задержка ОТПРАВКИ между пачками (сек)", "cfg_flood_buffer": "Дополнительное время к FloodWait (сек)", + "cfg_timezone": "Часовой пояс для времени в статусах (UTC offset, например 3 для MSK)", "copy_start_prem": ( '🚀 ChatCopy: Запуск копирования\n\n' "Источник: {src}\n" @@ -87,6 +96,7 @@ class ChatCopy(loader.Module): '👤 Без автора: {no_auth}\n' '💬 Без подписей: {no_capt}\n' '📎 Фильтр: {filter_type}\n' + '🚫 Игнор топиков: {ignored_topics}\n' '📦 Всего сообщений: {total_msgs}\n' '⏱ Оценка времени: {estimated_time}\n\n' "Задача добавлена в очередь. Позиция: {position}" @@ -101,6 +111,7 @@ class ChatCopy(loader.Module): "👤 Без автора: {no_auth}\n" "💬 Без подписей: {no_capt}\n" "📎 Фильтр: {filter_type}\n" + "🚫 Игнор топиков: {ignored_topics}\n" "📦 Всего сообщений: {total_msgs}\n" "⏱ Оценка времени: {estimated_time}\n\n" "Задача добавлена в очередь. Позиция: {position}" @@ -154,8 +165,9 @@ class ChatCopy(loader.Module): "forum_enable_failed": "❌ Не удалось включить топики в {chat}. Нужны права администратора.", "forum_not_channel": "❌ {chat} не является каналом/группой", "err_ent": "❌ Ошибка: Чат не найден или нет доступа.", - "args_err": "❌ Синтаксис: .chatcopy [start_id:final_id] [-n] [-dmc] [--now] [--media|--photo_video|--docs|--text]", - "watch_added": "👀 Наблюдение активировано\nID: {src_id}\n{src} -> {dest}\nРежим топиков: {topics}\nБез подписей: {no_capt}\nФильтр: {filter_type}", + "args_err": "❌ Синтаксис: .chatcopy [start_id:final_id] [-n] [-dmc] [--now] [--itopic 1|\"Имя\"] [-theme123] [--media|--photo_video|--docs|--text]", + "watch_added": "👀 Наблюдение активировано\nID: {src_id}\n{src} -> {dest}\nРежим топиков: {topics}\nБез подписей: {no_capt}\nФильтр: {filter_type}\nИгнор топиков: {ignored}", + "copy_restricted": "❌ Источник защищён запретом копирования/пересылки Telegram.\n\nМодуль остановлен до добавления в очередь: скрытый обход этой защиты не выполняется. Используй источник, где копирование разрешено, или отключи защиту в своём чате.", "queue_wait": "⏳ Задача в очереди... ({pos})", "topic_created": "📂 Создан топик: {title}", "topic_error": "❌ Ошибка создания топика: {error}", @@ -184,6 +196,7 @@ class ChatCopy(loader.Module): loader.ConfigValue("batch_size", 100, lambda: self.strings["cfg_batch"], validator=loader.validators.Integer(minimum=1, maximum=100)), loader.ConfigValue("delay", 10, lambda: self.strings["cfg_delay"], validator=loader.validators.Integer(minimum=1)), loader.ConfigValue("flood_buffer", 5, lambda: self.strings["cfg_flood_buffer"], validator=loader.validators.Integer(minimum=0, maximum=60)), + loader.ConfigValue("timezone_offset", 3, lambda: self.strings["cfg_timezone"], validator=loader.validators.Integer(minimum=-12, maximum=14)), ) self.queue = asyncio.Queue() self.dump_queue = asyncio.Queue() @@ -207,6 +220,7 @@ class ChatCopy(loader.Module): self.global_speed_history = [] self.avg_speed_history = [] self._queue_lock = asyncio.Lock() + self._send_lock = asyncio.Lock() self._task_counter = 0 async def client_ready(self, client, db): @@ -223,17 +237,20 @@ class ChatCopy(loader.Module): me = await client.get_me() self.is_premium = getattr(me, 'premium', False) try: - asset_channel = self._db.get("heroku.forums", "channel_id", 0) + asset_channel = ( + self.db.get("heroku.forums", "channel_id", 0) + or self.db.get("heroku.forums", "forum_id", 0) + ) if asset_channel: notif_topic = await utils.asset_forum_topic( - self._client, - self._db, + self.client, + self.db, asset_channel, "ChatCopy Logs", description="ChatCopy module activity logs (warnings & errors).", icon_emoji_id=5372917041193828849, ) - _cc_client = self._client + _cc_client = self.client _cc_log_channel = asset_channel _cc_log_topic_id = notif_topic.id logger.info("ChatCopy log topic ready (id=%s)", _cc_log_topic_id) @@ -266,10 +283,193 @@ class ChatCopy(loader.Module): "filter_type": task['filter_type'], "src_name": task['src'], "total_msgs": task['total_msgs'], "restored_count": task.get('current', 0), + "ignored_topics": task.get('ignored_topics', []), }) except Exception as e: logger.error(f"Не удалось возобновить задачу {task.get('tid')}: {e}") + def _tz(self): + offset = self.config.get("timezone_offset", 3) + try: + offset = int(offset) + except (TypeError, ValueError): + offset = 3 + offset = max(-12, min(14, offset)) + sign = "+" if offset >= 0 else "-" + name = "MSK" if offset == 3 else f"UTC{sign}{abs(offset):02d}:00" + return timezone(timedelta(hours=offset), name=name) + + def _now(self): + return datetime.now(self._tz()) + + def _time_from_ts(self, timestamp): + return datetime.fromtimestamp(timestamp, self._tz()) + + def _format_clock(self, value=None): + if value is None: + value = self._now() + if isinstance(value, (int, float)): + value = self._time_from_ts(value) + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=MSK).astimezone(self._tz()) + else: + value = value.astimezone(self._tz()) + return value.strftime("%H:%M:%S") + return str(value) + + def _split_args(self, message): + raw = utils.get_args_raw(message) + try: + return shlex.split(raw) + except ValueError: + return raw.split() + + def _normalize_topic_selector(self, value): + value = str(value).strip().strip("\"'").strip() + value = value.strip("{}") + return value.lower() + + def _format_ignored_topics(self, ignored_topics): + return ", ".join(ignored_topics) if ignored_topics else "Нет" + + def _topic_id_from_message(self, msg): + topic_id = None + if hasattr(msg, 'reply_to') and msg.reply_to: + topic_id = getattr(msg.reply_to, 'reply_to_top_id', None) or getattr(msg.reply_to, 'reply_to_msg_id', None) + if not topic_id and hasattr(msg, 'topic_id') and msg.topic_id: + topic_id = msg.topic_id + return topic_id + + def _topic_is_ignored(self, topic_id, title=None, ignored_topics=None): + if not ignored_topics: + return False + topic_id = topic_id if topic_id not in (None, "no_topic") else 1 + checks = {str(topic_id).lower()} + if title: + checks.add(str(title).strip().lower()) + return any(item in ignored_topics for item in checks) + + def _remove_premium_emojis(self, text, entities): + if not text or not entities or self.is_premium: + return text, entities + encoded = text.encode('utf-16-le') + new_entities = [] + result_utf16 = b"" + current_offset = 0 + offset_shift = 0 + for ent in sorted(entities, key=lambda e: e.offset): + if isinstance(ent, types.MessageEntityCustomEmoji): + result_utf16 += encoded[current_offset * 2:ent.offset * 2] + current_offset = ent.offset + ent.length + offset_shift += ent.length + continue + new_ent = copy.copy(ent) + new_ent.offset -= offset_shift + new_entities.append(new_ent) + result_utf16 += encoded[current_offset * 2:] + return result_utf16.decode('utf-16-le'), new_entities + + def _is_copy_restricted_error(self, exc): + name = exc.__class__.__name__.lower() + text = str(exc).lower() + return ( + "forwardsrestricted" in name + or "noforwards" in text + or "content is protected" in text + or "forwards restricted" in text + or "forbidden to forward" in text + ) + + async def _source_has_copy_restriction(self, entity): + if getattr(entity, 'noforwards', False): + return True + try: + async for msg in self.client.iter_messages(entity, limit=5): + if getattr(msg, 'noforwards', False): + return True + except Exception as e: + logger.debug("Не удалось проверить запрет копирования: %s", e) + return False + + async def _report_copy_restricted(self, status_msg, tid=None): + if tid and tid in self.active_dumps: + self.active_dumps[tid]["status"] = "error" + self.active_dumps[tid]["protected_error"] = True + try: + await utils.answer(status_msg, self.strings["copy_restricted"]) + except Exception: + try: + chat_id = getattr(status_msg, "chat_id", None) + if chat_id: + await self.client.send_message(chat_id, self.strings["copy_restricted"]) + except Exception: + pass + + async def _get_forum_topics(self, chat_entity, max_pages=50): + topics = [] + seen = set() + offset_date = None + offset_id = 0 + offset_topic = 0 + for _ in range(max_pages): + try: + result = await self.client(functions.messages.GetForumTopicsRequest( + peer=chat_entity, + offset_date=offset_date, + offset_id=offset_id, + offset_topic=offset_topic, + limit=100, + )) + except errors.FloodWaitError as e: + await asyncio.sleep(e.seconds + self.config["flood_buffer"]) + continue + except Exception as e: + logger.debug("GetForumTopics failed: %s", e) + break + page = getattr(result, 'topics', None) or [] + if not page: + break + added = 0 + for topic in page: + topic_id = getattr(topic, 'id', None) + if topic_id in seen: + continue + seen.add(topic_id) + topics.append(topic) + added += 1 + if added == 0 or len(page) < 100: + break + last = page[-1] + offset_topic = getattr(last, 'id', 0) or offset_topic + offset_id = getattr(last, 'top_message', 0) or offset_id + offset_date = getattr(last, 'date', 0) or offset_date + return topics + + async def _precreate_topics(self, src_entity, dest_entity, ignored_topics=None, selected_topic=None, tid=None): + if not src_entity or not dest_entity or not self._is_forum(src_entity) or not self._is_forum(dest_entity): + return 0 + topics = await self._get_forum_topics(src_entity) + created = 0 + for topic in topics: + topic_id = getattr(topic, 'id', None) + title = getattr(topic, 'title', None) or f"Topic {topic_id}" + if selected_topic and topic_id != selected_topic: + continue + if self._topic_is_ignored(topic_id, title, ignored_topics): + logger.info("[%s] Топик пропущен по игнору: %s (%s)", tid, title, topic_id) + continue + if topic_id == 1: + continue + mapped = await self._ensure_topic_mapping(src_entity, dest_entity, topic_id) + if mapped: + created += 1 + logger.info("[%s] Топик готов: %s (%s → %s)", tid, title, topic_id, mapped) + await asyncio.sleep(0.4) + if created: + logger.info("[%s] Подготовлено топиков: %d", tid, created) + return created + async def _resolve_arg(self, arg): # все виды (ну почти) ссылок как дадут id и прочее, # работает если копировать сообщение в топике и в аргумент типа куда отправлять вставить. extra = {} @@ -286,28 +486,38 @@ class ChatCopy(loader.Module): try: entity = await self.client.get_entity(potential_id) if entity: break - except: continue + except Exception: + continue else: - try: entity = await self.client.get_entity(identifier) - except: pass + try: + entity = await self.client.get_entity(identifier) + except Exception: + pass else: try: - if arg.lstrip("-").isdigit(): entity = await self.client.get_entity(int(arg)) - else: entity = await self.client.get_entity(arg) - except: pass + if arg.lstrip("-").isdigit(): + entity = await self.client.get_entity(int(arg)) + else: + entity = await self.client.get_entity(arg) + except Exception: + pass return entity, extra def _get_normalized_id(self, entity): # что бы получать норм айди а не нечто, что бы копировка шла хорошо. if not entity: return "0" + try: + return str(tl_utils.get_peer_id(entity)) + except Exception: + pass try: return str(utils.get_chat_id(entity)) except Exception: if hasattr(entity, 'id') and entity.id: eid = str(entity.id) - if not eid.startswith("-100") and len(eid) > 9: + if isinstance(entity, Channel) and not eid.startswith("-100") and len(eid) > 9: return f"-100{eid}" - if not eid.startswith("-"): + if isinstance(entity, Channel) and not eid.startswith("-"): return f"-100{eid}" return eid return "0" @@ -365,7 +575,7 @@ class ChatCopy(loader.Module): pass if not title: try: - result = await self.client(functions.messages.GetForumTopicsRequest(peer=chat_entity, offset_date=0, offset_id=0, offset_topic=0, limit=100)) + result = await self.client(functions.messages.GetForumTopicsRequest(peer=chat_entity, offset_date=None, offset_id=0, offset_topic=0, limit=100)) if result and hasattr(result, 'topics'): for topic in result.topics: if hasattr(topic, 'id') and topic.id == topic_id: @@ -396,6 +606,9 @@ class ChatCopy(loader.Module): return None try: random_id = random.randint(1, 2**63 - 1) + if icon_emoji_id and not self.is_premium: + logger.debug("Сбрасываем premium icon_emoji_id для топика %s: аккаунт без Premium", title) + icon_emoji_id = None kwargs = { "peer": dest_entity, "title": title[:128] if len(title) > 128 else title, @@ -428,7 +641,7 @@ class ChatCopy(loader.Module): break if not new_topic_id: await asyncio.sleep(1) - topics_result = await self.client(functions.messages.GetForumTopicsRequest(peer=dest_entity, offset_date=0, offset_id=0, offset_topic=0, limit=20)) + topics_result = await self.client(functions.messages.GetForumTopicsRequest(peer=dest_entity, offset_date=None, offset_id=0, offset_topic=0, limit=20)) if topics_result and hasattr(topics_result, 'topics'): for topic in topics_result.topics: if getattr(topic, 'title', '') == title: @@ -457,8 +670,10 @@ class ChatCopy(loader.Module): title, icon_emoji_id, icon_color = await self._get_topic_info(src_entity, src_topic_id) if not title: title = f"Topic {src_topic_id}" + if icon_emoji_id and not self.is_premium: + icon_emoji_id = None try: - offset_date = 0 + offset_date = None offset_id = 0 offset_topic = 0 found_topic_id = None @@ -548,10 +763,10 @@ class ChatCopy(loader.Module): task_id, total_msgs=0, speed=0): # ниже этой функции, функция обработки флудвейта, он просто отправляет примерное время когда продолжит работать. minutes = seconds // 60 secs = seconds % 60 - resume_time = (datetime.now(MSK) + timedelta(seconds=seconds + self.config["flood_buffer"])).strftime("%H:%M:%S") + resume_time = (self._now() + timedelta(seconds=seconds + self.config["flood_buffer"])).strftime("%H:%M:%S") remaining = max(0, total_msgs - count) self.last_flood_info = { - "time": datetime.now(MSK).strftime("%H:%M:%S"), + "time": self._format_clock(), "duration": seconds, "task": task_id, "resume_at": resume_time @@ -597,12 +812,12 @@ class ChatCopy(loader.Module): async def _process_batch(self, messages, dest_id, no_author, no_captions=False, fixed_dest_topic=None, map_topics=False, dest_entity=None, - src_entity=None, filter_type=FILTER_ALL, status_msg=None, tid=None): + src_entity=None, filter_type=FILTER_ALL, status_msg=None, tid=None, ignored_topics=None): if not messages: return 0 if tid and tid in self.active_dumps: await self.active_dumps[tid].get("cancel", asyncio.Event()).wait() - if self.active_dumps[tid].get("status") == "stopped": + if self.active_dumps[tid].get("status") in ("stopped", "error"): return 0 filtered_messages = [msg for msg in messages if self._should_include_message(msg, filter_type)] if not filtered_messages: @@ -621,10 +836,7 @@ class ChatCopy(loader.Module): for msg in filtered_messages: src_topic_id = None if map_topics and src_entity and dest_entity: - if hasattr(msg, 'reply_to') and msg.reply_to: - src_topic_id = getattr(msg.reply_to, 'reply_to_top_id', None) or getattr(msg.reply_to, 'reply_to_msg_id', None) - if not src_topic_id and hasattr(msg, 'topic_id') and msg.topic_id: - src_topic_id = msg.topic_id + src_topic_id = self._topic_id_from_message(msg) key = src_topic_id if src_topic_id else "no_topic" msg_groups.setdefault(key, []).append(msg) total_sent = 0 @@ -632,15 +844,25 @@ class ChatCopy(loader.Module): if not isinstance(delay, int): delay = 10 for src_topic_id, msgs in msg_groups.items(): + if ignored_topics: + topic_title = None + if src_topic_id != "no_topic" and src_entity: + topic_title, _, _ = await self._get_topic_info(src_entity, src_topic_id) + if self._topic_is_ignored(src_topic_id, topic_title, ignored_topics): + logger.info("[%s] Пропуск топика по игнору: %s (%s)", tid, topic_title or "General", src_topic_id) + continue if tid and tid in self.active_dumps: await self.active_dumps[tid].get("cancel", asyncio.Event()).wait() - if self.active_dumps[tid].get("status") == "stopped": + if self.active_dumps[tid].get("status") in ("stopped", "error"): break target_topic = fixed_dest_topic - if map_topics and src_topic_id != "no_topic": + if map_topics and src_topic_id != "no_topic" and int(src_topic_id) != 1: target_topic = await self._ensure_topic_mapping(src_entity, dest_entity, src_topic_id) if not target_topic: - continue + logger.error("[%s] Не удалось создать/найти топик назначения для source topic %s", tid, src_topic_id) + if tid and tid in self.active_dumps: + self.active_dumps[tid]["status"] = "error" + break if tid and tid in self.active_dumps: last_send = self.active_dumps[tid].get("last_successful_send", 0) time_since_last = time.time() - last_send @@ -654,6 +876,13 @@ class ChatCopy(loader.Module): total_sent += len(msgs) if tid and tid in self.active_dumps: self.active_dumps[tid]["last_successful_send"] = time.time() + elif tid and tid in self.active_dumps and self.active_dumps[tid].get("status") in ("stopped", "error"): + break + else: + logger.error("[%s] Отправка пачки не удалась, останавливаю задачу без продвижения last_processed_id", tid) + if tid and tid in self.active_dumps: + self.active_dumps[tid]["status"] = "error" + break await asyncio.sleep(delay) return total_sent @@ -661,7 +890,7 @@ class ChatCopy(loader.Module): while True: item = await self.queue.get() try: - watch_cid = item.get("watch_cid") + watch_cid = item.pop("watch_cid", None) if watch_cid and watch_cid not in self.watchlist: logger.debug(f"Игнорируем сообщение для {watch_cid}, слежка была остановлена") continue @@ -681,59 +910,68 @@ class ChatCopy(loader.Module): while True: task_data = await self.dump_queue.get() tid = task_data.get('tid') - async with self._queue_lock: - self.is_processing_queue = True - self.current_dump_task = tid - self._update_queue_positions() - if tid in self.task_queue: - idx = next((i for i, t in enumerate(self.task_queue) if t['tid'] == tid), None) + update_task = None + try: + async with self._queue_lock: + self.is_processing_queue = True + self.current_dump_task = tid + self._update_queue_positions() + idx = next((i for i, t in enumerate(self.task_queue) if t.get('tid') == tid), None) if idx is not None: self.task_queue[idx]['status'] = 'running' - self.task_queue[idx]['start_time'] = datetime.now(MSK) + self.task_queue[idx]['start_time'] = self._now() self.current_task_index = idx - if tid: - self.active_dumps[tid] = { - "current": 0, - "cancel": asyncio.Event(), - "name": task_data.get('src_name', 'Unknown'), - "status": "running", - "start_time": time.time(), - "flood_count": 0, - "flood_total_seconds": 0, - "status_msg_id": task_data.get('status_msg').id if task_data.get('status_msg') else None, - "status_chat_id": task_data.get('status_msg').chat_id if task_data.get('status_msg') else None, - "total_estimated": task_data.get('total_msgs', 0), - "last_update_time": time.time(), - "last_update_count": 0, - "last_successful_send": time.time(), - "consecutive_floods": 0, - "speed_samples": [], - "current_speed": 0, - } - self.active_dumps[tid]["cancel"].set() + if tid: + self.active_dumps[tid] = { + "current": task_data.get('restored_count', 0), + "cancel": asyncio.Event(), + "name": task_data.get('src_name', 'Unknown'), + "src": task_data.get('src_name', 'Unknown'), + "dest": getattr(task_data.get('dest'), 'title', task_data.get('dest', 'Unknown')), + "status": "running", + "start_time": time.time(), + "flood_count": 0, + "flood_total_seconds": 0, + "status_msg_id": task_data.get('status_msg').id if task_data.get('status_msg') else None, + "status_chat_id": task_data.get('status_msg').chat_id if task_data.get('status_msg') else None, + "total_estimated": task_data.get('total_msgs', 0), + "last_update_time": time.time(), + "last_update_count": 0, + "last_successful_send": time.time(), + "consecutive_floods": 0, + "speed_samples": [], + "current_speed": 0, + } + self.active_dumps[tid]["cancel"].set() + self._save_tasks() update_task = asyncio.create_task(self._auto_update_status(tid, task_data.get('status_msg'))) - try: - logger.info("[%s] Задача запущена: %s → %s | Всего: %d сообщений", - tid, task_data.get('src_name', '?'), - getattr(task_data.get('dest'), 'title', '?'), - task_data.get('total_msgs', 0)) - await self._history_dumper(**task_data) - except Exception as e: - logger.error(f"Dump Worker Error: {e}") - if tid and tid in self.active_dumps: - self.active_dumps[tid]["status"] = "error" - finally: + logger.info("[%s] Задача запущена: %s → %s | Всего: %d сообщений", + tid, task_data.get('src_name', '?'), + getattr(task_data.get('dest'), 'title', '?'), + task_data.get('total_msgs', 0)) + await self._history_dumper(**task_data) + except Exception as e: + logger.error(f"Dump Worker Error: {e}", exc_info=True) + if tid and tid in self.active_dumps: + self.active_dumps[tid]["status"] = "error" + finally: + if update_task: update_task.cancel() + last_task = None + sent_count = 0 + async with self._queue_lock: if tid in self.active_dumps: completed_task = self.active_dumps[tid].copy() completed_task['tid'] = tid - completed_task['end_time'] = datetime.now(MSK) + completed_task['end_time'] = self._now() + sent_count = completed_task.get('current', 0) self.task_history.append(completed_task) self.task_queue = [t for t in self.task_queue if t['tid'] != tid] duration = time.time() - completed_task.get('start_time', time.time()) active_duration = duration - completed_task.get('flood_total_seconds', 0) - if active_duration <= 0: active_duration = 1 - avg_spd = (completed_task.get('current', 0) / active_duration) * 60 + if active_duration <= 0: + active_duration = 1 + avg_spd = (sent_count / active_duration) * 60 self.task_stats[tid] = { 'completed_at': time.time() if completed_task.get('status') == 'completed' else None, 'flood_count': completed_task.get('flood_count', 0), @@ -741,18 +979,17 @@ class ChatCopy(loader.Module): 'avg_speed': avg_spd } self.db.set("ChatCopy", "task_stats", self.task_stats) - logger.info("[%s] Задача завершена. Переслано: %d", - tid, self.active_dumps.get(tid, {}).get('current', 0)) + self.active_dumps.pop(tid, None) + last_task = completed_task self.current_dump_task = None self.is_processing_queue = False + self._save_tasks() self.dump_queue.task_done() - if tid and tid in self.task_history: - last_task = next((t for t in reversed(self.task_history) if t.get('tid') == tid), None) - if last_task and last_task.get('flood_count', 0) > 0: - final_wait = min(60 * last_task['flood_count'], 300) - logger.info(f"Финальная задержка после задачи с FloodWait'ами: {final_wait}с") - await asyncio.sleep(final_wait) - self._save_tasks() + logger.info("[%s] Задача завершена. Переслано: %d", tid, sent_count) + if last_task and last_task.get('flood_count', 0) > 0: + final_wait = min(60 * last_task['flood_count'], 300) + logger.info(f"Финальная задержка после задачи с FloodWait'ами: {final_wait}с") + await asyncio.sleep(final_wait) def _update_queue_positions(self): # описание ниже """Обновляет позиции задач в очереди""" @@ -853,7 +1090,14 @@ class ChatCopy(loader.Module): return end_time.strftime("%H:%M:%S") async def _raw_sender(self, messages, dest_id, no_author, no_captions, topic_id, status_msg=None, tid=None): # описание ниже - """Улучшенный sender с умной обработкой FloodWait""" + """Единая точка отправки: один аккаунт не должен слать пачки параллельно.""" + async with self._send_lock: + return await self._raw_sender_unlocked( + messages, dest_id, no_author, no_captions, topic_id, status_msg, tid + ) + + async def _raw_sender_unlocked(self, messages, dest_id, no_author, no_captions, topic_id, status_msg=None, tid=None): + """Улучшенный sender с умной обработкой FloodWait.""" try: dest_peer = await self.client.get_input_entity(dest_id) src_peer = await self.client.get_input_entity(messages[0].chat_id) @@ -912,26 +1156,45 @@ class ChatCopy(loader.Module): return False return False except Exception as e: + if self._is_copy_restricted_error(e): + logger.warning("[%s] Источник защищён запретом копирования/пересылки Telegram", tid) + await self._report_copy_restricted(status_msg, tid) + return False logger.error(f"[{tid}] Send Error: {e}") return False - def _parse_filter(self, args): # все аргументы нужные цепляет + def _parse_filter_and_ignored(self, args): # все аргументы нужные цепляет filter_type = FILTER_ALL - args_list = list(args) - for arg in args_list: + ignored_topics = [] + clean_args = [] + i = 0 + while i < len(args): + arg = args[i] if arg == "--media": filter_type = FILTER_MEDIA - if arg in args: args.remove(arg) elif arg == "--photo_video": filter_type = FILTER_PHOTO_VIDEO - if arg in args: args.remove(arg) elif arg == "--docs": filter_type = FILTER_DOCS - if arg in args: args.remove(arg) elif arg == "--text": filter_type = FILTER_TEXT - if arg in args: args.remove(arg) - return filter_type, args + elif arg in ("--itopic", "--ignore-topic", "--theme", "-theme"): + if i + 1 < len(args): + ignored_topics.append(self._normalize_topic_selector(args[i + 1])) + i += 1 + elif arg.startswith("--itopic=") or arg.startswith("--theme="): + ignored_topics.append(self._normalize_topic_selector(arg.split("=", 1)[1])) + elif arg.startswith("-theme") and len(arg) > len("-theme"): + ignored_topics.append(self._normalize_topic_selector(arg[len("-theme"):].lstrip("=:"))) + else: + clean_args.append(arg) + i += 1 + ignored_topics = [item for item in dict.fromkeys(ignored_topics) if item] + return filter_type, ignored_topics, clean_args + + def _parse_filter(self, args): + filter_type, _, clean_args = self._parse_filter_and_ignored(args) + return filter_type, clean_args def _get_filter_name(self, filter_type): names = { @@ -952,15 +1215,15 @@ class ChatCopy(loader.Module): @loader.command() async def chatcopy(self, message: Message): - """ [start_id:final_id] [-n] [-dmc] [--now] [--media|--photo_video|--docs|--text] — Добавить задачу в очередь. --now: начать сразу, без полного подсчёта.""" - args_raw = utils.get_args_raw(message).split() + """ [start_id:final_id] [-n] [-dmc] [--now] [--itopic 1] [-theme123] [--media|--photo_video|--docs|--text] — Добавить задачу в очередь.""" + args_raw = self._split_args(message) no_author = "-n" in args_raw no_captions = "-dmc" in args_raw start_now = "--now" in args_raw if start_now: args_raw.remove("--now") - filter_type, args_raw = self._parse_filter(args_raw) - clean_args = [x for x in args_raw if x not in ["-n", "-dmc"]] + args_raw = [x for x in args_raw if x not in ["-n", "-dmc"]] + filter_type, ignored_topics, clean_args = self._parse_filter_and_ignored(args_raw) if len(clean_args) < 2: return await utils.answer(message, self.strings["args_err"]) start_id = 0 @@ -979,8 +1242,12 @@ class ChatCopy(loader.Module): dest, dest_map = await self._resolve_arg(clean_args[1]) if not src or not dest: return await utils.answer(message, self.strings["err_ent"]) + if await self._source_has_copy_restriction(src): + return await utils.answer(message, self.strings["copy_restricted"]) + src_peer_id = int(self._get_normalized_id(src)) + dest_peer_id = int(self._get_normalized_id(dest)) self._task_counter += 1 - tid = f"{src.id}_{dest.id}_{self._task_counter}_{int(time.time())}" + tid = f"{src_peer_id}_{dest_peer_id}_{self._task_counter}_{int(time.time())}" src_is_forum = self._is_forum(src) dest_is_forum = self._is_forum(dest) if src_is_forum and not dest_is_forum: @@ -1061,23 +1328,25 @@ class ChatCopy(loader.Module): start_id_str = f"с {start_id}" if start_id > 0 else "С начала" if final_id > 0: start_id_str += f" до {final_id}" task_info = { - 'tid': tid, 'src': src_name, 'dest': dest_name, 'src_id': src.id, 'dest_id': dest.id, - 'status': 'queued', 'position': queue_position, 'added_time': datetime.now(MSK).isoformat(), + 'tid': tid, 'src': src_name, 'dest': dest_name, 'src_id': src_peer_id, 'dest_id': dest_peer_id, + 'status': 'queued', 'position': queue_position, 'added_time': self._now().isoformat(), 'no_author': no_author, 'no_captions': no_captions, 'filter_type': filter_type, 'start_id': start_id, 'final_id': final_id, 'total_msgs': total_msgs if total_msgs > -1 else 0, - 'current': 0, 'last_processed_id': start_id, + 'current': 0, 'last_processed_id': start_id - 1 if start_id > 0 else 0, 'status_msg_id': status_msg.id, 'status_chat_id': status_msg.chat_id, 'map_t': src_is_forum, 'f_src_t': src_map.get('topic'), 'f_dest_t': dest_map.get('topic'), - 'start_now': start_now, + 'start_now': start_now, 'ignored_topics': ignored_topics, } self.task_queue.append(task_info) self._save_tasks() filter_name = self._get_filter_name(filter_type) + ignored_str = self._format_ignored_topics(ignored_topics) start_string_key = "copy_start_prem" if self.is_premium else "copy_start_no_prem" await status_msg.edit(self.strings[start_string_key].format( src=utils.escape_html(src_name), dest=utils.escape_html(dest_name), mode=mode_str, start_id=start_id_str, no_auth=no_auth_str, no_capt=no_capt_str, filter_type=filter_name, + ignored_topics=ignored_str, total_msgs=total_msgs if total_msgs > -1 else "∞ (ошибка подсчета)", estimated_time=estimated_duration, position=queue_position )) @@ -1087,9 +1356,9 @@ class ChatCopy(loader.Module): "map_t": src_is_forum, "f_src_t": src_map.get('topic'), "f_dest_t": dest_map.get('topic'), "tid": tid, "min_id": start_id, "max_id": final_id, "mode_str": mode_str, "no_auth_str": no_auth_str, "no_capt_str": no_capt_str, - "start_id_str": start_id_str, "filter_type": filter_name, "filter_name": filter_name, + "start_id_str": start_id_str, "filter_type": filter_type, "filter_name": filter_name, "src_name": src_name, "queue_position": queue_position, "total_msgs": total_msgs if total_msgs > -1 else 0, - "restored_count": 0, + "restored_count": 0, "ignored_topics": ignored_topics, }) def _parse_duration(self, duration_str): # описание ниже @@ -1119,12 +1388,12 @@ class ChatCopy(loader.Module): @loader.command() # стартует слежку за чатом что бы пи... кхм кхм, благополучно заимствовать сей прекрасный или не очень контент async def ccwatch(self, message: Message): - """ [start_id:final_id] [-n] [-dmc][--media|--photo_video|--docs|--text] — Наблюдение за чатом""" - args = utils.get_args_raw(message).split() + """ [start_id:final_id] [-n] [-dmc] [--itopic 1] [-theme123] [--media|--photo_video|--docs|--text] — Наблюдение за чатом""" + args = self._split_args(message) no_author = "-n" in args no_captions = "-dmc" in args - filter_type, args = self._parse_filter(args) - clean_args = [x for x in args if x not in ["-n", "-t", "-dmc"]] + args = [x for x in args if x not in ["-n", "-t", "-dmc"]] + filter_type, ignored_topics, clean_args = self._parse_filter_and_ignored(args) if len(clean_args) < 2: return await utils.answer(message, self.strings["args_err"]) start_id = 0 @@ -1141,6 +1410,8 @@ class ChatCopy(loader.Module): dest, dest_map = await self._resolve_arg(clean_args[1]) if not src or not dest: return await utils.answer(message, self.strings["err_ent"]) + if await self._source_has_copy_restriction(src): + return await utils.answer(message, self.strings["copy_restricted"]) src_is_forum = self._is_forum(src) dest_is_forum = self._is_forum(dest) if src_is_forum and not dest_is_forum: @@ -1150,23 +1421,15 @@ class ChatCopy(loader.Module): dest = await self.client.get_entity(dest.id) else: return await utils.answer(message, self.strings["forum_enable_failed"].format(chat=utils.escape_html(getattr(dest, 'title', dest.id)))) - is_restricted = False - try: - async for test_m in self.client.iter_messages(src, limit=1): - if test_m.noforwards: - is_restricted = True - break - except Exception: - pass - if is_restricted: - return await utils.answer(message, "❌ Ошибка: канал в режиме запрета копирования") # ну как бы, учитываем да src_t = src_map.get('topic') dest_t = dest_map.get('topic') map_topics = src_is_forum cid = self._get_normalized_id(src) - try: - dest_id = utils.get_chat_id(dest) - except: + src_peer_id = int(cid) + dest_peer_id = int(self._get_normalized_id(dest)) + try: + dest_id = dest_peer_id + except Exception: dest_id = dest.id if start_id > 0: self.last_processed_ids[cid] = start_id - 1 @@ -1174,15 +1437,19 @@ class ChatCopy(loader.Module): self.last_processed_ids[cid] = 0 self.watchlist[cid] = { "dest": dest_id, "no_author": no_author, "no_captions": no_captions, "map_topics": map_topics, - "fixed_src_topic": src_t, "fixed_dest_topic": dest_t, "src_entity_id": src.id, "dest_entity_id": dest.id, - "filter_type": filter_type, "final_id": final_id + "fixed_src_topic": src_t, "fixed_dest_topic": dest_t, "src_entity_id": src_peer_id, "dest_entity_id": dest_peer_id, + "filter_type": filter_type, "final_id": final_id, "ignored_topics": ignored_topics } self.db.set("ChatCopy", "watchlist", self.watchlist) self.db.set("ChatCopy", "last_processed_ids", self.last_processed_ids) filter_name = self._get_filter_name(filter_type) + ignored_str = self._format_ignored_topics(ignored_topics) msg_text = self.strings["watch_added"].format( src=getattr(src, 'title', src.id), src_id=cid, dest=getattr(dest, 'title', dest.id), - topics="🗂️ ВКЛ (Auto-mapping)" if map_topics else "ВЫКЛ", no_capt="Да" if no_captions else "Нет", filter_type=filter_name + topics="🗂️ ВКЛ (Auto-mapping)" if map_topics else "ВЫКЛ", + no_capt="Да" if no_captions else "Нет", + filter_type=filter_name, + ignored=ignored_str ) if start_id > 0 or final_id > 0: range_str = "Все новые" @@ -1194,7 +1461,10 @@ class ChatCopy(loader.Module): async def _history_dumper(self, status_msg, src, dest, no_auth, no_captions, map_t, f_src_t, f_dest_t, tid, min_id=0, max_id=0, - filter_type=FILTER_ALL, filter_name="", restored_count=0, **kwargs): + filter_type=FILTER_ALL, filter_name="", restored_count=0, + ignored_topics=None, **kwargs): + if ignored_topics is None: + ignored_topics = [] if tid in self.active_dumps: self.active_dumps[tid]["status"] = "running" task = next((t for t in self.task_queue if t['tid'] == tid), None) @@ -1204,7 +1474,7 @@ class ChatCopy(loader.Module): count = task.get('current', 0) or restored_count if tid in self.active_dumps and count > 0: self.active_dumps[tid]["current"] = count - start_from_id = task.get('last_processed_id', min_id) + start_from_id = task.get('last_processed_id', min_id - 1 if min_id > 0 else 0) if map_t: try: dest = await self.client.get_entity(dest.id) @@ -1236,26 +1506,31 @@ class ChatCopy(loader.Module): map_t = False except Exception as e: logger.warning("[%s] Ошибка обновления src entity: %s", tid, e) + if map_t and self._is_forum(src) and self._is_forum(dest): + await self._precreate_topics(src, dest, ignored_topics, f_src_t, tid) batch = [] dumper_kwargs = {"reverse": True} if f_src_t: dumper_kwargs["reply_to"] = f_src_t - if start_from_id > 0: dumper_kwargs["min_id"] = start_from_id - 1 + if start_from_id > 0: dumper_kwargs["min_id"] = start_from_id if max_id > 0: dumper_kwargs["max_id"] = max_id + 1 + dest_peer_id = int(self._get_normalized_id(dest)) delay = self.config["delay"] try: async for msg in self.client.iter_messages(src, **dumper_kwargs): - if tid not in self.active_dumps or self.active_dumps[tid].get("status") == "stopped": break + if tid not in self.active_dumps or self.active_dumps[tid].get("status") in ("stopped", "error"): break await self.active_dumps[tid].get("cancel", asyncio.Event()).wait() - if tid not in self.active_dumps or self.active_dumps[tid].get("status") == "stopped": break + if tid not in self.active_dumps or self.active_dumps[tid].get("status") in ("stopped", "error"): break if isinstance(msg, types.MessageService) or not self._should_include_message(msg, filter_type): continue batch.append(msg) if len(batch) >= self._get_effective_batch_size(): processed = await self._process_batch( - messages=list(batch), dest_id=dest.id, no_author=no_auth, no_captions=no_captions, + messages=list(batch), dest_id=dest_peer_id, no_author=no_auth, no_captions=no_captions, fixed_dest_topic=f_dest_t, map_topics=map_t, dest_entity=dest, src_entity=src, - filter_type=filter_type, status_msg=status_msg, tid=tid + filter_type=filter_type, status_msg=status_msg, tid=tid, + ignored_topics=ignored_topics ) if tid not in self.active_dumps or self.active_dumps[tid].get("status") == "stopped": break + if self.active_dumps[tid].get("status") == "error": break if tid in self.active_dumps: self.active_dumps[tid]["current"] += processed count = self.active_dumps[tid]["current"] @@ -1268,19 +1543,22 @@ class ChatCopy(loader.Module): logger.info("[%s] Прогресс: %d/%d (%.1f%%) | %.1f сооб/мин", tid, count, total, pct, spd) batch = [] - if batch and self.active_dumps.get(tid, {}).get("status") != "stopped": + if batch and self.active_dumps.get(tid, {}).get("status") not in ("stopped", "error"): processed = await self._process_batch( - messages=list(batch), dest_id=dest.id, no_author=no_auth, no_captions=no_captions, + messages=list(batch), dest_id=dest_peer_id, no_author=no_auth, no_captions=no_captions, fixed_dest_topic=f_dest_t, map_topics=map_t, dest_entity=dest, src_entity=src, - filter_type=filter_type, status_msg=status_msg, tid=tid + filter_type=filter_type, status_msg=status_msg, tid=tid, + ignored_topics=ignored_topics ) - if tid in self.active_dumps: + if tid in self.active_dumps and self.active_dumps[tid].get("status") not in ("stopped", "error"): self.active_dumps[tid]["current"] += processed count = self.active_dumps[tid]["current"] task['current'] = count task['last_processed_id'] = batch[-1].id - if self.active_dumps.get(tid, {}).get("status") != "stopped": + if self.active_dumps.get(tid, {}).get("status") not in ("stopped", "error"): task['status'] = 'completed' + if tid in self.active_dumps: + self.active_dumps[tid]["status"] = "completed" self.task_queue = [t for t in self.task_queue if t['tid'] != tid] self._save_tasks() task_data = self.active_dumps[tid] @@ -1311,10 +1589,9 @@ class ChatCopy(loader.Module): chat_id_to_report = status_msg.chat_id if status_msg and status_msg.chat_id else task.get('status_chat_id') if chat_id_to_report: await self.client.send_message(chat_id_to_report, f"❌ Ошибка в задаче:\n{e}") task['status'] = 'error' + if tid in self.active_dumps: + self.active_dumps[tid]["status"] = "error" self._save_tasks() - except Exception as e: - logger.error(f"Dumper Error: {e}") - await self.client.send_message(status_msg.chat_id, f"❌ Ошибка в задаче:\n{e}") @loader.watcher() # сам ватчер, который следит за чатами async def watcher(self, message: Message): @@ -1357,17 +1634,21 @@ class ChatCopy(loader.Module): self.db.set("ChatCopy", "last_processed_ids", self.last_processed_ids) return if cfg.get("fixed_src_topic"): - cur_t = getattr(message, 'topic_id', None) or (message.reply_to.reply_to_top_id if message.reply_to else None) + cur_t = self._topic_id_from_message(message) if cur_t != cfg["fixed_src_topic"]: self.last_processed_ids[cid] = message.id self.db.set("ChatCopy", "last_processed_ids", self.last_processed_ids) return + if cfg.get("ignored_topics") and self._topic_is_ignored(self._topic_id_from_message(message), None, cfg.get("ignored_topics")): + self.last_processed_ids[cid] = message.id + self.db.set("ChatCopy", "last_processed_ids", self.last_processed_ids) + return if cid not in self.watcher_buffer: self.watcher_buffer[cid] = [] self.watcher_buffer[cid].append(message) self.last_watched[cid] = { "name": getattr(getattr(message, 'chat', None), "title", cid) if getattr(message, 'chat', None) else cid, - "time": datetime.now(MSK).strftime("%H:%M:%S") + "time": self._format_clock() } if cid in self.watcher_flush_tasks: self.watcher_flush_tasks[cid].cancel() @@ -1418,7 +1699,8 @@ class ChatCopy(loader.Module): "dest_entity": dest_entity, "src_entity": src_entity, "filter_type": cfg.get("filter_type", FILTER_ALL), - "watch_cid": cid + "watch_cid": cid, + "ignored_topics": cfg.get("ignored_topics", []) }) except Exception as e: logger.error(f"Watcher album flush error (cid={cid}): {e}") @@ -1440,7 +1722,8 @@ class ChatCopy(loader.Module): "dest_entity": dest_entity, "src_entity": src_entity, "filter_type": cfg.get("filter_type", FILTER_ALL), - "watch_cid": cid + "watch_cid": cid, + "ignored_topics": cfg.get("ignored_topics", []) }) except Exception as e: logger.error(f"Watcher batch flush error (cid={cid}): {e}") @@ -1457,6 +1740,7 @@ class ChatCopy(loader.Module): if not isinstance(batch_size, int): batch_size = 100 filter_type = cfg.get("filter_type", FILTER_ALL) + ignored_topics = cfg.get("ignored_topics", []) cid_int = int(cid_str) async for msg in self.client.iter_messages(cid_int, min_id=last_id): if cfg.get("final_id", 0) > 0 and msg.id > cfg.get("final_id", 0): @@ -1473,7 +1757,8 @@ class ChatCopy(loader.Module): "messages": batch, "dest_id": cfg["dest"], "no_author": cfg["no_author"], "no_captions": cfg.get("no_captions", False), "fixed_dest_topic": cfg.get("fixed_dest_topic"), "map_topics": cfg.get("map_topics"), "dest_entity": dest_ent, "src_entity": src_ent, - "filter_type": filter_type, "watch_cid": cid_str + "filter_type": filter_type, "watch_cid": cid_str, + "ignored_topics": ignored_topics }) await asyncio.sleep(self.config["delay"]) except Exception as e: @@ -1485,10 +1770,10 @@ class ChatCopy(loader.Module): help_text_prem = ( '🛡 Подробная документация по модулю ChatCopy!\n\n' '
1️⃣ Основные команды \n' - '🛫 .chatcopy <откуда> <куда>[диапазон (от:до)] [флаги (можно несколько)]\n' + '🛫 .chatcopy <откуда> <куда> [диапазон] [--itopic 1|\"Имя\"] [-theme123] [флаги]\n' 'Копирует старую историю чата (делает дамп). Ставит задачу в очередь в случае если другая была запущена.\n' - '⚙️ --now — Начать немедленно, без полного подсчёта (примерное кол-во сообщений запрашивается у Telegram мгновенно). Идеально для 110k+ медиа.\n\n' - '👀 .ccwatch <откуда> <куда> [диапазон (от:до)] [флаги (можно несколько)]\n' + '⚙️ --now — Начать немедленно, без полного подсчёта (примерное кол-во сообщений запрашивается у Telegram).\n\n' + '👀 .ccwatch <откуда> <куда> [диапазон] [--itopic 1|\"Имя\"] [флаги]\n' 'Режим слежки. Модуль будет висеть в фоне и моментально пересылать все новые сообщения. Функции [от:до] аналогичны .chatcopy\n\n' '📺 .ccpanel\n' 'Открывает меню: управление задачами, пауза/стоп, статистика и настройки (скорость, задержка).\n\n' @@ -1501,7 +1786,8 @@ class ChatCopy(loader.Module): '⚪️ 100: — от 100-го до самых свежих.\n' '⚪️ :500 — с самого начала чата и до 500-го.
\n\n' '
3️⃣ Флаги (Настройки текста)\n' - '🆕 --now - начать пересылку сразу без подсчитывания, но без копирования топиков и последующей пересылки в них' + '🆕 --now — начать без полного ручного подсчёта.\n' + '🚫 --itopic 1, --itopic "Название", -theme123 — игнор топиков по ID или имени.\n' '👤 -n — Скрыть автора (пересылка без плашки «Переслано от...»).\n' '💬 -dmc — Удалить подпись к медиа (оставит только голую картинку или файл, удалив текст под ним)(!Работает только с[-n] флагом!).
\n\n' '
4️⃣ Фильтры контента\n' @@ -1524,10 +1810,10 @@ class ChatCopy(loader.Module): help_text_no_prem = ( '🛡 Подробная документация по модулю ChatCopy!\n\n' '
1️⃣ Основные команды \n' - '🛫 .chatcopy <откуда> <куда>[диапазон (от:до)] [флаги (можно несколько)]\n' + '🛫 .chatcopy <откуда> <куда> [диапазон] [--itopic 1|"Имя"] [-theme123] [флаги]\n' 'Копирует старую историю чата (делает дамп). Ставит задачу в очередь в случае если другая была запущена.\n' - '⚙️ --now — Начать немедленно, без полного подсчёта (примерное кол-во запрашивается у Telegram мгновенно). Идеально для 110k+ медиа.\n\n' - '👀 .ccwatch <откуда> <куда> [диапазон (от:до)] [флаги (можно несколько)]\n' + '⚙️ --now — Начать немедленно, без полного подсчёта (примерное кол-во запрашивается у Telegram).\n\n' + '👀 .ccwatch <откуда> <куда> [диапазон] [--itopic 1|"Имя"] [флаги]\n' 'Режим слежки. Модуль будет висеть в фоне и моментально пересылать все новые сообщения. Функции [от:до] аналогичны .chatcopy\n\n' '📺 .ccpanel\n' 'Открывает меню: управление задачами, пауза/стоп, статистика и настройки (скорость, задержка).\n\n' @@ -1540,7 +1826,8 @@ class ChatCopy(loader.Module): '⚪️ 100: — от 100-го до самых свежих.\n' '⚪️ :500 — с самого начала чата и до 500-го.
\n\n' '
3️⃣ Флаги (Настройки текста)\n' - '🆕 --now - начать пересылку сразу без подсчитывания, но без копирования топиков и последующей пересылки в них' + '🆕 --now — начать без полного ручного подсчёта.\n' + '🚫 --itopic 1, --itopic "Название", -theme123 — игнор топиков по ID или имени.\n' '👤 -n — Скрыть автора (пересылка без плашки «Переслано от...»).\n' '💬 -dmc — Удалить подпись к медиа (оставит только голую картинку или файл, удалив текст под ним)(!Работает только с [-n] флагом!).
\n\n' '
4️⃣ Фильтры контента\n' @@ -1583,8 +1870,9 @@ class ChatCopy(loader.Module): progress = round((count / total * 100), 1) if total > 0 else 0 eta = self._calculate_eta(count, total, speed) elapsed_str = self._format_duration(elapsed) - start_time = datetime.fromtimestamp(start_ts, MSK).strftime("%H:%M:%S") - end_time = self._calculate_end_time(datetime.fromtimestamp(start_ts, MSK), total - count, speed) + start_dt = self._time_from_ts(start_ts) + start_time = self._format_clock(start_dt) + end_time = self._calculate_end_time(start_dt, total - count, speed) active_text = self.strings["panel_task_running"].format( name=name, count=count, @@ -1600,7 +1888,7 @@ class ChatCopy(loader.Module): current_fw = task.get('current_flood_wait', 0) fw_str = f"{current_fw // 60}m {current_fw % 60}s" if current_fw >= 60 else f"{current_fw}s" resume_at = task.get('flood_wait_until', 0) - resume_time = datetime.fromtimestamp(resume_at, MSK).strftime("%H:%M:%S") if resume_at else "неизвестно" + resume_time = self._format_clock(resume_at) if resume_at else "неизвестно" active_text = self.strings["panel_task_paused"].format( name=name, flood_time=fw_str, @@ -1700,12 +1988,13 @@ class ChatCopy(loader.Module): current = active_data.get('current', 0) speed = active_data.get('current_speed', 0) start_ts = active_data.get('start_time', time.time()) - start_time = datetime.fromtimestamp(start_ts, MSK).strftime("%H:%M:%S") + start_dt = self._time_from_ts(start_ts) + start_time = self._format_clock(start_dt) elapsed = time.time() - start_ts elapsed_str = self._format_duration(elapsed) progress = round((current / total * 100), 1) if total > 0 else 0 eta_left = self._calculate_eta(current, total, speed) - end_time = self._calculate_end_time(datetime.fromtimestamp(start_ts, MSK), total - current, speed) + end_time = self._calculate_end_time(start_dt, total - current, speed) text = self.strings["task_detail_running"].format( num=num, src=src, dest=dest, current=current, total=total, progress=progress, speed=round(speed, 1), eta_left=eta_left, @@ -1733,7 +2022,7 @@ class ChatCopy(loader.Module): flood_seconds = active_data.get('flood_total_seconds', 0) speed = active_data.get('current_speed', 0) resume_at = active_data.get('flood_wait_until', 0) - resume_time = datetime.fromtimestamp(resume_at, MSK).strftime("%H:%M:%S") if resume_at else "неизвестно" + resume_time = self._format_clock(resume_at) if resume_at else "неизвестно" progress = round((current / total * 100), 1) if total > 0 else 0 remaining = max(0, total - current) text = self.strings["task_detail_paused"].format( @@ -1763,14 +2052,14 @@ class ChatCopy(loader.Module): src = utils.escape_html(task.get('src', 'Unknown')) dest = utils.escape_html(task.get('dest', 'Unknown')) count = task.get('current', 0) - end_time = task.get('end_time', datetime.now(MSK)) + end_time = task.get('end_time', self._now()) if isinstance(end_time, datetime): - end_time_str = end_time.strftime("%H:%M:%S") + end_time_str = self._format_clock(end_time) else: end_time_str = str(end_time) start_ts = task.get('start_time', time.time()) if isinstance(start_ts, (int, float)): - start_dt = datetime.fromtimestamp(start_ts) + start_dt = self._time_from_ts(start_ts) duration_seconds = time.time() - start_ts else: start_dt = start_ts @@ -1816,11 +2105,14 @@ class ChatCopy(loader.Module): self.active_dumps[tid]["status"] = "stopped" self.active_dumps[tid]["cancel"].set() self.task_queue = [t for t in self.task_queue if t['tid'] != tid] + self._save_tasks() return await self._panel_tasks(call) else: if action == "stop": self.task_queue = [t for t in self.task_queue if t['tid'] != tid] + self._save_tasks() return await self._panel_tasks(call) + self._save_tasks() await self._show_task_detail(call, tid, 0) async def _stop_specific(self, call, tid): # останавливаем определенную задачу (копирование) @@ -1858,7 +2150,8 @@ class ChatCopy(loader.Module): f"⚙️ Настройки\n\n" f"Batch size: {self.config['batch_size']}\n" f"Delay: {self.config['delay']} сек\n" - f"FloodWait buffer: {self.config['flood_buffer']} сек" + f"FloodWait buffer: {self.config['flood_buffer']} сек\n" + f"Timezone: UTC{self.config['timezone_offset']:+d}" ) btns = [ [{"text": "📦 +10", "callback": self._change_setting, "args": ["batch_size", 10]}, @@ -1867,6 +2160,8 @@ class ChatCopy(loader.Module): {"text": "⏱ -5с", "callback": self._change_setting, "args": ["delay", -5]}], [{"text": "🛡️ +5с буфер", "callback": self._change_setting, "args": ["flood_buffer", 5]}, {"text": "🛡️ -5с буфер", "callback": self._change_setting, "args": ["flood_buffer", -5]}], + [{"text": "🕒 UTC +1", "callback": self._change_setting, "args": ["timezone_offset", 1]}, + {"text": "🕒 UTC -1", "callback": self._change_setting, "args": ["timezone_offset", -1]}], [{"text": "🗑 Очистить кэш топиков", "callback": self._clear_topics_cache}], [{"text": self.strings["btn_back"], "callback": self._cb_back}] ] @@ -1912,6 +2207,8 @@ class ChatCopy(loader.Module): new_val = min(100, max(1, new_val)) elif key == "flood_buffer": new_val = min(60, max(0, new_val)) + elif key == "timezone_offset": + new_val = min(14, max(-12, new_val)) else: new_val = max(1, new_val) self.config[key] = new_val diff --git a/SenkoGuardian/SenModules/Gemini.py b/SenkoGuardian/SenModules/Gemini.py index ec4a4a4..0bfc508 100644 --- a/SenkoGuardian/SenModules/Gemini.py +++ b/SenkoGuardian/SenModules/Gemini.py @@ -7,7 +7,7 @@ # meta banner: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png # meta pic: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png -__version__ = ("6", "3", "0") +__version__ = ("6", "5", "0") """ ̄へ ̄""" @@ -31,6 +31,7 @@ import json import asyncio import logging import tempfile +import time import aiohttp from markdown_it import MarkdownIt import pytz @@ -95,9 +96,13 @@ DB_IMPERSONATION_KEY = "gemini_impersonation_chats" DB_PRESETS_KEY = "gemini_prompt_presets" DB_PAGER_CACHE_KEY = "gemini_pager_cache" DB_KEY_MAP_KEY = "gemini_key_model_map" +DB_MEMORY_DISABLED_KEY = "gemini_memory_disabled_chats" +DB_SESSION_STATS_KEY = "gemini_session_stats_v1" +DB_PROVIDER_MODELS_KEY = "gemini_provider_models_v1" GEMINI_TIMEOUT = 840 MAX_FFMPEG_SIZE = 90 * 1024 * 1024 CHECK_MODEL = "gemini-2.5-pro" +MODEL_PROFILE_CHOICES = ("auto", "balanced", "fast", "reasoning", "coding", "vision", "manual") # requires: google-genai google-api-core pytz markdown_it_py @@ -119,6 +124,11 @@ class Gemini(loader.Module): "cfg_google_search_doc": "Включить поиск Google (Grounding) для актуальной информации.", "cfg_image_model_doc": "Модель Gemini для генерации изображений (например: gemini-2.5-flash-image).", "cfg_inline_pagination_doc": "Использовать инлайн-кнопки для длинных ответов.", + "cfg_global_memory_doc": "Включить ОБЩУЮ память для всех чатов.", + "cfg_show_tokens_doc": "Показывать токены в ответе, если провайдер их вернул.", + "cfg_show_time_doc": "Показывать время выполнения запроса.", + "cfg_auto_model_doc": "Автоматически подбирать модель по профилю и запросу.", + "cfg_model_profile_doc": "Профиль модели: auto, balanced, fast, reasoning, coding, vision, manual.", "no_api_key": ( '❗️ Api ключ(и) не настроен(ы).\nПолучить Api ключ можно здесь.\n' 'Добавьте ключ(и) в конфиге модуля: .cfg gemini api_key\n' @@ -141,9 +151,13 @@ class Gemini(loader.Module): "unsupported_media_type": "⚠️ Формат медиа ({}) не поддерживается.", "memory_status": "🧠 [{}/{}]", "memory_status_unlimited": "🧠 [{}/∞]", + "memory_status_global": "🧠 [🌍 GLOBAL/{}]", "memory_cleared": "🧹 Память диалога очищена.", + "memory_cleared_global": "🧹 Глобальная память очищена.", "memory_cleared_gauto": "🧹 Память gauto в этом чате очищена.", "no_memory_to_clear": "ℹ️ В этом чате нет истории.", + "gres_global_cleared": "🧹 Вся глобальная память очищена.", + "gres_no_global": "ℹ️ Глобальная память и так пуста.", "no_gauto_memory_to_clear": "ℹ️ В этом чате нет истории gauto.", "memory_chats_title": "🧠 Чаты с историей ({}):", "memory_chat_line": " • {} ({})", @@ -157,8 +171,8 @@ class Gemini(loader.Module): "no_memory_to_fully_clear": "ℹ️ Память Gemini и так пуста.", "no_gauto_memory_to_fully_clear": "ℹ️ Память gauto и так пуста.", "response_too_long": "Ответ Gemini был слишком длинным и отправлен в виде файла.", - "gclear_usage": "ℹ️ Использование: .gclear [auto]", - "gres_usage": "ℹ️ Использование: .gres [auto]", + "gclear_usage": "ℹ️ Использование: .gclear [global/auto]", + "gres_usage": "ℹ️ Использование: .gres [global/auto]", "auto_mode_on": "🎭 Режим авто-ответа включен в этом чате.\nЯ буду отвечать на сообщения с вероятностью {}%.", "auto_mode_off": "🎭 Режим авто-ответа выключен в этом чате.", "auto_mode_chats_title": "🎭 Чаты с активным авто-ответом ({}):", @@ -174,7 +188,13 @@ class Gemini(loader.Module): "gch_result_caption_from_chat": "Анализ последних {} сообщений из чата {}", "gch_invalid_args": "❗️ Неверные аргументы.\n{}", "gch_chat_error": "❗️ Ошибка доступа к чату {}: {}", - "gmodel_usage": "ℹ️ Использование: .gmodel [модель] [-s]\n• [модель] — установить модель.\n• -s — показать список доступных моделей.", + "gask_no_prompt": "⚠️ Введите вопрос или ответьте командой на сообщение.", + "gprovider_usage": "ℹ️ Использование: .gprovider [gemini/openrouter]", + "gprovider_current": "🧩 Текущий провайдер: {}\n🧠 Модель: {}\n\n.gprovider gemini\n.gprovider openrouter", + "gprovider_set": "✅ Провайдер: {}\n🧠 Модель: {}", + "gprofile_usage": "ℹ️ Использование: .gprofile [auto|balanced|fast|reasoning|coding|vision|manual]", + "gprofile_set": "✅ Профиль модели: {}\n🧠 Для текущего провайдера: {}", + "gmodel_usage": "ℹ️ Использование: .gmodel [модель] [--s|-s]\n• [модель] — установить модель.\n• --s/-s — показать список доступных моделей.", "gmodel_list_title": "📋 Доступные модели Gemini (по вашему API):", "gmodel_list_item": "• {} — {} (поддержка: {})", "gmodel_img_support": "Поддержка изображений", @@ -213,15 +233,66 @@ class Gemini(loader.Module): "application/javascript", "application/x-sh", } + CORE_PROVIDER_ORDER = ("google", "openrouter") + + PROVIDER_SPECS = { + "google": { + "label": "Gemini", + "default_model": "gemini-3-flash-preview", + "docs_url": "https://ai.google.dev/gemini-api/docs/models", + "model_prefixes": ("gemini", "imagen", "lyria", "veo"), + "profiles": { + "balanced": "gemini-3-flash-preview", + "fast": "gemini-2.5-flash", + "reasoning": "gemini-3.1-pro-preview", + "coding": "gemini-3.1-pro-preview-custom-tools", + "vision": "gemini-3-flash-preview", + }, + "fallback_models": ( + "gemini-3-flash-preview", + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-2.5-flash-lite", + "gemini-2.5-flash-image", + ), + }, + "openrouter": { + "label": "OpenRouter", + "default_model": "google/gemini-3-flash-preview", + "docs_url": "https://openrouter.ai/docs/docs/overview/models", + "model_prefixes": ("/",), + "profiles": { + "balanced": "google/gemini-3-flash-preview", + "fast": "google/gemini-3.1-flash-lite-preview", + "reasoning": "google/gemini-3.1-pro-preview", + "coding": "anthropic/claude-sonnet-4.6", + "vision": "google/gemini-3-flash-preview", + }, + "fallback_models": ( + "google/gemini-3-flash-preview", + "google/gemini-2.5-flash", + "google/gemini-2.5-pro", + "anthropic/claude-sonnet-4", + "openai/gpt-4o", + "deepseek/deepseek-r1", + ), + }, + } + def __init__(self): self.config = loader.ModuleConfig( loader.ConfigValue("api_key", "", self.strings["cfg_api_key_doc"], validator=loader.validators.Hidden()), loader.ConfigValue("Openrouter_api_key", "", "API Key от OpenRouter (получить тут).", validator=loader.validators.Hidden()), - loader.ConfigValue("provider", "google", "Провайдер API: 'google' или 'openrouter'.", validator=loader.validators.Choice(["google", "openrouter"])), - loader.ConfigValue("model_name", "gemini-2.5-flash", self.strings["cfg_model_name_doc"]), + loader.ConfigValue("provider", "google", "Провайдер API: Gemini или OpenRouter.", validator=loader.validators.Choice(["google", "openrouter"])), + loader.ConfigValue("model_name", "gemini-3-flash-preview", self.strings["cfg_model_name_doc"]), loader.ConfigValue("interactive_buttons", True, self.strings["cfg_buttons_doc"], validator=loader.validators.Boolean()), loader.ConfigValue("system_instruction", "", self.strings["cfg_system_instruction_doc"], validator=loader.validators.String()), loader.ConfigValue("max_history_length", 800, self.strings["cfg_max_history_length_doc"], validator=loader.validators.Integer(minimum=0)), + loader.ConfigValue("global_memory", False, self.strings["cfg_global_memory_doc"], validator=loader.validators.Boolean()), + loader.ConfigValue("show_tokens", True, self.strings["cfg_show_tokens_doc"], validator=loader.validators.Boolean()), + loader.ConfigValue("show_time", True, self.strings["cfg_show_time_doc"], validator=loader.validators.Boolean()), + loader.ConfigValue("auto_model", False, self.strings["cfg_auto_model_doc"], validator=loader.validators.Boolean()), + loader.ConfigValue("model_profile", "manual", self.strings["cfg_model_profile_doc"], validator=loader.validators.Choice(list(MODEL_PROFILE_CHOICES))), loader.ConfigValue("timezone", "Europe/Moscow", self.strings["cfg_timezone_doc"]), loader.ConfigValue("proxy", "", self.strings["cfg_proxy_doc"]), loader.ConfigValue( @@ -252,6 +323,9 @@ class Gemini(loader.Module): self.memory_disabled_chats = set() self.pager_cache = {} self.key_model_map = {} + self.provider_models = {} + self.key_cooldowns = {} + self.session_stats = {"requests": 0, "tokens_in": 0, "tokens_out": 0, "times": [], "start_time": time.time()} self.api_keys =[] async def client_ready(self, client, db): @@ -261,6 +335,19 @@ class Gemini(loader.Module): api_key_str = self.config["api_key"] self.api_keys =[k.strip() for k in api_key_str.split(",") if k.strip()] if api_key_str else[] self.key_model_map = self.db.get(self.strings["name"], DB_KEY_MAP_KEY, {}) + self.provider_models = self.db.get(self.strings["name"], DB_PROVIDER_MODELS_KEY, {}) + if not isinstance(self.provider_models, dict): + self.provider_models = {} + self.memory_disabled_chats = set(self.db.get(self.strings["name"], DB_MEMORY_DISABLED_KEY, [])) + saved_stats = self.db.get(self.strings["name"], DB_SESSION_STATS_KEY, {}) + if isinstance(saved_stats, dict): + self.session_stats.update({ + "requests": int(saved_stats.get("requests", 0) or 0), + "tokens_in": int(saved_stats.get("tokens_in", 0) or 0), + "tokens_out": int(saved_stats.get("tokens_out", 0) or 0), + "times": list(saved_stats.get("times", []) or [])[-200:], + "start_time": time.time(), + }) keys_to_remove =[k for k in self.key_model_map if k not in self.api_keys] if keys_to_remove: for k in keys_to_remove: del self.key_model_map[k] @@ -296,6 +383,181 @@ class Gemini(loader.Module): except Exception: pass + def _normalize_provider_name(self, provider: str = None) -> str: + provider = str(provider or self.config["provider"] or "google").strip().lower() + return {"gemini": "google", "google": "google", "or": "openrouter", "openrouter": "openrouter"}.get(provider, provider) + + def _provider_spec(self, provider: str = None) -> dict: + return self.PROVIDER_SPECS.get(self._normalize_provider_name(provider), self.PROVIDER_SPECS["google"]) + + def _provider_label(self, provider: str = None) -> str: + return self._provider_spec(provider).get("label", "Gemini") + + def _provider_default_model(self, provider: str = None) -> str: + return self._provider_spec(provider).get("default_model", "gemini-3-flash-preview") + + def _save_provider_models(self): + self.db.set(self.strings["name"], DB_PROVIDER_MODELS_KEY, self.provider_models) + + def _provider_model_entry(self, provider: str = None) -> dict: + provider = self._normalize_provider_name(provider) + entry = self.provider_models.get(provider, "") + if isinstance(entry, dict): + return { + "model": str(entry.get("model") or "").strip(), + "manual": bool(entry.get("manual", True)), + "profile": str(entry.get("profile") or "manual").strip().lower(), + "auto_model": bool(entry.get("auto_model", False)), + } + value = str(entry or "").strip() + return {"model": value, "manual": bool(value), "profile": "manual", "auto_model": False} + + def _remember_provider_model(self, provider: str = None, model_name: str = None, manual: bool = None): + provider = self._normalize_provider_name(provider) + if provider not in self.PROVIDER_SPECS: + return + model_name = str(model_name or self.config.get("model_name") or "").strip() + if not model_name: + return + if manual is None: + manual = (not self.config.get("auto_model", False)) or str(self.config.get("model_profile") or "").lower() == "manual" + self.provider_models[provider] = { + "model": model_name, + "manual": bool(manual), + "profile": str(self.config.get("model_profile") or ("manual" if manual else "auto")).strip().lower(), + "auto_model": bool(self.config.get("auto_model", False)) if not manual else False, + } + self._save_provider_models() + + def _restore_provider_model(self, provider: str) -> str: + provider = self._normalize_provider_name(provider) + entry = self._provider_model_entry(provider) + saved = entry.get("model") + if saved: + self.config["model_name"] = saved + self.config["auto_model"] = bool(entry.get("auto_model", False)) if not entry.get("manual", True) else False + profile = str(entry.get("profile") or "manual").lower() + self.config["model_profile"] = profile if profile in MODEL_PROFILE_CHOICES else "manual" + return saved + default = self._provider_default_model(provider) + self.config["model_name"] = default + return default + + def _provider_profile_models(self, provider: str = None) -> dict: + provider = self._normalize_provider_name(provider) + profiles = dict(self._provider_spec(provider).get("profiles", {}) or {}) + default = self._provider_default_model(provider) + profiles.setdefault("auto", default) + profiles.setdefault("balanced", default) + profiles.setdefault("manual", self.config.get("model_name") or default) + return profiles + + def _provider_curated_models(self, provider: str = None) -> list: + models = list(self._provider_spec(provider).get("fallback_models", ()) or ()) + return list(dict.fromkeys([str(model).strip() for model in models if str(model).strip()])) + + def _model_matches_provider(self, model_name: str, provider: str) -> bool: + model = str(model_name or "").strip().lower() + provider = self._normalize_provider_name(provider) + if not model: + return True + if provider == "google": + return model.startswith(("gemini", "imagen", "lyria", "veo")) and "/" not in model + if provider == "openrouter": + return "/" in model or model.startswith(("openrouter/", "google/", "anthropic/", "openai/", "deepseek/")) + return False + + def _parts_have_image_like_media(self, parts: list) -> bool: + for part in parts or []: + inline = getattr(part, "inline_data", None) + if not inline: + continue + mime = str(getattr(inline, "mime_type", "") or "").lower() + if mime.startswith(("image/", "video/")): + return True + return False + + def _guess_model_profile_from_request(self, parts: list, request_text: str = "") -> str: + if self._parts_have_image_like_media(parts): + return "vision" + text = str(request_text or "") + for part in parts or []: + if getattr(part, "text", None): + text += "\n" + str(part.text) + low = text.lower() + if any(h in low for h in ("код", "скрипт", "traceback", "stack trace", "python", "javascript", "typescript", "api", "regex", "pytest", "docker")): + return "coding" + if any(h in low for h in ("объясни", "проанализируй", "сравни", "докажи", "архитектур", "reason", "solve", "proof")): + return "reasoning" + return "balanced" + + def _resolve_effective_model(self, provider: str, configured_model: str = None, parts: list = None, request_text: str = "") -> str: + provider = self._normalize_provider_name(provider) + configured = str(configured_model or self.config.get("model_name") or "").strip() + default = self._provider_default_model(provider) + if configured and not self._model_matches_provider(configured, provider): + configured = "" + if not self.config.get("auto_model", False): + return configured or default + profile = str(self.config.get("model_profile") or "auto").strip().lower() + if profile not in MODEL_PROFILE_CHOICES: + profile = "auto" + if profile == "manual": + return configured or default + selected = self._guess_model_profile_from_request(parts or [], request_text) if profile == "auto" else profile + profiles = self._provider_profile_models(provider) + return profiles.get(selected) or profiles.get("balanced") or configured or default + + def _extract_request_text_for_display(self, parts: list, fallback: str = None) -> str: + if fallback: + return fallback + chunks = [] + for part in parts or []: + text = getattr(part, "text", None) + if text: + chunks.append(str(text)) + return "\n".join(chunks).strip() or "[медиа-запрос]" + + def _record_session_usage(self, tokens_in: int = 0, tokens_out: int = 0, elapsed: float = 0.0): + self.session_stats["requests"] = int(self.session_stats.get("requests", 0) or 0) + 1 + self.session_stats["tokens_in"] = int(self.session_stats.get("tokens_in", 0) or 0) + int(tokens_in or 0) + self.session_stats["tokens_out"] = int(self.session_stats.get("tokens_out", 0) or 0) + int(tokens_out or 0) + times = list(self.session_stats.get("times", []) or []) + times.append(float(elapsed or 0)) + self.session_stats["times"] = times[-200:] + self.db.set(self.strings["name"], DB_SESSION_STATS_KEY, { + "requests": self.session_stats["requests"], + "tokens_in": self.session_stats["tokens_in"], + "tokens_out": self.session_stats["tokens_out"], + "times": self.session_stats["times"], + }) + + def _model_info_line(self, provider: str, model: str, elapsed: float = 0.0, tokens_in: int = 0, tokens_out: int = 0) -> str: + extra = "" + if self.config.get("show_time", True): + extra += f" ⏱️{round(float(elapsed or 0), 1)}с" + if self.config.get("show_tokens", True) and (tokens_in or tokens_out): + extra += f" 🪙{int(tokens_in or 0) + int(tokens_out or 0)}" + return f"{self._provider_label(provider)}: {utils.escape_html(str(model))}{extra}" + + def _extract_retry_delay_seconds(self, text: str, default: int = 3600) -> int: + raw = str(text or "") + match = re.search(r"retryDelay['\"]?\s*[:=]\s*['\"]?(\d+)s", raw, flags=re.IGNORECASE) + if match: + return max(60, min(int(match.group(1)), 86400)) + match = re.search(r"retry after\s+(\d+)", raw, flags=re.IGNORECASE) + if match: + return max(60, min(int(match.group(1)), 86400)) + return default + + def _set_key_cooldown(self, key: str, seconds: int): + if key: + self.key_cooldowns[str(key)] = time.time() + max(60, int(seconds or 3600)) + + def _get_openrouter_keys(self) -> list: + raw = str(self.config.get("Openrouter_api_key") or "") + return [key.strip() for key in raw.split(",") if key.strip()] + async def _prepare_parts(self, message: Message, custom_text: str=None): final_parts, warnings = [], [] prompt_text_chunks =[] @@ -411,34 +673,38 @@ class Gemini(loader.Module): final_parts.insert(0, types.Part(text=full_prompt_text)) return final_parts, warnings - async def _send_to_gemini(self, message, parts: list, regeneration: bool=False, call: InlineCall=None, status_msg=None, chat_id_override: int=None, impersonation_mode: bool=False, use_url_context: bool=False, display_prompt: str=None): + async def _send_to_gemini(self, message, parts: list, regeneration: bool=False, call: InlineCall=None, status_msg=None, chat_id_override: int=None, impersonation_mode: bool=False, use_url_context: bool=False, display_prompt: str=None, attempt: int = 1, is_retry: bool = False, ephemeral: bool = False): msg_obj = None - if regeneration: + if regeneration or is_retry: chat_id = chat_id_override; base_message_id = message try: msg_obj = await self.client.get_messages(chat_id, ids=base_message_id) except Exception: msg_obj = None else: chat_id = utils.get_chat_id(message); base_message_id = message.id; msg_obj = message - target_model = self.config["model_name"] - if self.config["provider"] == "openrouter": - if regeneration: + provider = self._normalize_provider_name() + is_global = self.config["global_memory"] and not impersonation_mode + history_key = "global_context" if is_global else str(chat_id) + target_model = self._resolve_effective_model(provider, self.config["model_name"], parts, display_prompt or "") + if provider == "openrouter": + if regeneration or is_retry: current_turn_parts, request_text_for_display = self.last_requests.get(f"{chat_id}:{base_message_id}", (parts, "[регенерация]")) else: current_turn_parts = parts - user_text_from_parts = " ".join([p.text for p in parts if hasattr(p, "text") and p.text]) - request_text_for_display = display_prompt or user_text_from_parts or "[медиа-запрос]" + request_text_for_display = self._extract_request_text_for_display(parts, display_prompt) self.last_requests[f"{chat_id}:{base_message_id}"] = (current_turn_parts, request_text_for_display) try: + target_model = self._resolve_effective_model("openrouter", self.config["model_name"], current_turn_parts, request_text_for_display) sys_instruct = self.config["system_instruction"] or None if impersonation_mode: my_name = get_display_name(self.me) chat_history_text = await self._get_recent_chat_text(chat_id) sys_instruct = self.config["impersonation_prompt"].format(my_name=my_name, chat_history=chat_history_text) - raw_hist = self._get_structured_history(chat_id, gauto=impersonation_mode) + raw_hist = self._get_structured_history(history_key, gauto=impersonation_mode) if regeneration and raw_hist: raw_hist = raw_hist[:-2] openai_messages = self._convert_google_history_to_openai(raw_hist, sys_instruct) content_list =[] + media_notes = [] for p in current_turn_parts: if hasattr(p, "text") and p.text: content_list.append({"type": "text", "text": p.text}) @@ -451,24 +717,48 @@ class Gemini(loader.Module): "type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64_img}"} }) + elif mime.startswith("audio/"): + media_notes.append("[аудиофайл]") + elif mime.startswith("video/"): + media_notes.append("[видеофайл]") + else: + media_notes.append("[файл]") + if media_notes: + note = "Контекст медиа для OpenRouter: " + ", ".join(media_notes) + if content_list and isinstance(content_list, list) and content_list[0].get("type") == "text": + content_list[0]["text"] = note + "\n\n" + content_list[0]["text"] + else: + content_list.insert(0, {"type": "text", "text": note}) if not content_list: content_list = request_text_for_display openai_messages.append({"role": "user", "content": content_list}) - result_text = await self._send_to_Openrouter_api(target_model, openai_messages, self.config["temperature"]) + _t_start = time.time() + result_text, usage = await self._send_to_Openrouter_api(target_model, openai_messages, self.config["temperature"]) + _elapsed = round(time.time() - _t_start, 1) + _tokens_in = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0) + _tokens_out = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0) + if not (_tokens_in or _tokens_out) and usage.get("total_tokens"): + _tokens_out = int(usage.get("total_tokens") or 0) result_text = result_text.strip() result_text = re.sub(r"^\[System Info:.*?\]\s*", "", result_text, flags=re.IGNORECASE) result_text = re.sub(r"^\[\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE) result_text = re.sub(r"^\[\d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE) - if self._is_memory_enabled(str(chat_id)): - self._update_history(chat_id, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode) + if not impersonation_mode: + self._record_session_usage(_tokens_in, _tokens_out, _elapsed) + if self._is_memory_enabled(str(chat_id)) and not ephemeral: + self._update_history(history_key, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode) if impersonation_mode: return result_text - hist_len = len(self._get_structured_history(chat_id)) // 2 + hist_len = len(self._get_structured_history(history_key)) // 2 max_hist = self.config["max_history_length"] - if max_hist <= 0: + if is_global: + mem_indicator = self.strings["memory_status_global"].format(hist_len) + elif max_hist <= 0: mem_indicator = self.strings["memory_status_unlimited"].format(hist_len) else: mem_indicator = self.strings["memory_status"].format(hist_len, max_hist) - model_info = f"OpenRouter: {target_model}" + model_info = self._model_info_line("openrouter", target_model, _elapsed, _tokens_in, _tokens_out) + if attempt > 1: + model_info += f" (Успешно с {attempt}-й попытки)" response_html = self._markdown_to_html(result_text) formatted_body = self._format_response_with_smart_separation(response_html) question_html = f"
{utils.escape_html(request_text_for_display[:200])}
" @@ -488,25 +778,36 @@ class Gemini(loader.Module): return "" except Exception as e: error_text = self._handle_error(e) + error_buttons = None + if not impersonation_mode and base_message_id: + btn_action = "regen_att" if regeneration else "retry" + is_regen_flag = "1" if regeneration else "0" + error_buttons = [[ + {"text": f"🔄 Повторить ({attempt + 1})", "data": f"gemini:{btn_action}:{chat_id}:{base_message_id}:{attempt + 1}"}, + {"text": "👁 Запрос", "data": f"gemini:shreq:{is_regen_flag}:{chat_id}:{base_message_id}:{attempt + 1}"} + ]] if impersonation_mode: logger.error(f"Gauto/Openrouter error: {error_text}") - elif call: await call.edit(error_text) - elif status_msg: await utils.answer(status_msg, error_text) + elif call: await call.edit(error_text, reply_markup=error_buttons) + elif status_msg: await utils.answer(status_msg, error_text, reply_markup=error_buttons) return None api_keys_to_use = self._get_sorted_keys() if not api_keys_to_use: if not impersonation_mode and status_msg: await utils.answer(status_msg, self.strings['no_api_key']) return None if impersonation_mode else "" - if regeneration: + if regeneration or is_retry: current_turn_parts, request_text_for_display = self.last_requests.get(f"{chat_id}:{base_message_id}", (parts, "[регенерация]")) else: current_turn_parts = parts - request_text_for_display = display_prompt or (self.strings["media_reply_placeholder"] if any(getattr(p, 'inline_data', None) for p in parts) else "") + request_text_for_display = self._extract_request_text_for_display(parts, display_prompt) self.last_requests[f"{chat_id}:{base_message_id}"] = (current_turn_parts, request_text_for_display) + target_model = self._resolve_effective_model("google", self.config["model_name"], current_turn_parts, request_text_for_display) result_text = "" last_error = None was_successful = False search_icon = "" max_retries = len(api_keys_to_use) + _tokens_in = 0 + _tokens_out = 0 if impersonation_mode: my_name = get_display_name(self.me) chat_history_text = await self._get_recent_chat_text(chat_id) @@ -515,7 +816,7 @@ class Gemini(loader.Module): sys_val = self.config["system_instruction"] sys_instruct = (sys_val.strip() if isinstance(sys_val, str) else "") or None contents =[] - raw_hist = self._get_structured_history(chat_id, gauto=impersonation_mode) + raw_hist = self._get_structured_history(history_key, gauto=impersonation_mode) if regeneration and raw_hist: raw_hist = raw_hist[:-2] try: user_tz = pytz.timezone(self.config["timezone"]) @@ -551,6 +852,7 @@ class Gemini(loader.Module): ] ) proxy_config = self._get_proxy_config() + _t_start = time.time() for i in range(max_retries): api_key = api_keys_to_use[i] try: @@ -565,6 +867,9 @@ class Gemini(loader.Module): ) if response.text: result_text = response.text + if getattr(response, "usage_metadata", None): + _tokens_in = getattr(response.usage_metadata, "prompt_token_count", 0) or 0 + _tokens_out = getattr(response.usage_metadata, "candidates_token_count", 0) or 0 result_text = result_text.strip() result_text = re.sub(r"^\[System Info:.*?\]\s*", "", result_text, flags=re.IGNORECASE) result_text = re.sub(r"^\[\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE) @@ -575,24 +880,46 @@ class Gemini(loader.Module): else: raise ValueError("Empty response") except Exception as e: err_str = str(e).lower() - if any(x in err_str for x in["quota", "exhausted", "429", "permission_denied", "blocked", "403", "client application", "bad request", "400", "INVALID_ARGUMENT"]): - if i == max_retries - 1: last_error = RuntimeError(f"All keys exhausted or blocked. Last: {e}") - continue + if any(x in err_str for x in["quota", "exhausted", "429"]): + self._set_key_cooldown(api_key, self._extract_retry_delay_seconds(str(e), 3600)) + self.key_model_map[api_key] = 0 + self.db.set(self.strings["name"], DB_KEY_MAP_KEY, self.key_model_map) + if i == max_retries - 1: last_error = RuntimeError(f"All keys exhausted or blocked. Last: {e}") + continue + if any(x in err_str for x in["permission_denied", "api key not valid", "api_key_invalid", "client application"]) and "model" not in err_str: + self._set_key_cooldown(api_key, 86400 * 365) + self.key_model_map[api_key] = -1 + self.db.set(self.strings["name"], DB_KEY_MAP_KEY, self.key_model_map) + if i == max_retries - 1: last_error = RuntimeError(f"All keys invalid or blocked. Last: {e}") + continue + if any(x in err_str for x in["blocked", "403", "bad request", "400", "invalid_argument"]): + if i == max_retries - 1: last_error = RuntimeError(f"All keys exhausted or blocked. Last: {e}") + continue + if any(x in err_str for x in["500", "503", "internal", "unavailable", "timeout"]): + if i == max_retries - 1: last_error = RuntimeError(f"Google API is currently unstable. Last: {e}") + continue else: last_error = e break + _elapsed = round(time.time() - _t_start, 1) try: if not was_successful: raise last_error or RuntimeError("Unknown generation error") - if self._is_memory_enabled(str(chat_id)): - self._update_history(chat_id, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode) + if not impersonation_mode: + self._record_session_usage(_tokens_in, _tokens_out, _elapsed) + if self._is_memory_enabled(str(chat_id)) and not ephemeral: + self._update_history(history_key, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode) if impersonation_mode: return result_text - hist_len_pairs = len(self._get_structured_history(chat_id, gauto=False)) // 2 + hist_len_pairs = len(self._get_structured_history(history_key, gauto=False)) // 2 max_hist = self.config["max_history_length"] - if max_hist <= 0: + if is_global: + mem_indicator = self.strings["memory_status_global"].format(hist_len_pairs) + elif max_hist <= 0: mem_indicator = self.strings["memory_status_unlimited"].format(hist_len_pairs) else: mem_indicator = self.strings["memory_status"].format(hist_len_pairs, max_hist) - model_info = f"Модель: {self.config['model_name']}" + model_info = self._model_info_line("google", target_model, _elapsed, _tokens_in, _tokens_out) + if attempt > 1: + model_info += f" (Успешно с {attempt}-й попытки)" is_long_text = len(result_text) > 3500 if is_long_text and self.config["inline_pagination"]: chunks = self._paginate_text(result_text, 3000) @@ -627,9 +954,17 @@ class Gemini(loader.Module): elif status_msg: await utils.answer(status_msg, text_to_send, reply_markup=buttons) except Exception as e: error_text = self._handle_error(e) + error_buttons = None + if not impersonation_mode and base_message_id: + btn_action = "regen_att" if regeneration else "retry" + is_regen_flag = "1" if regeneration else "0" + error_buttons = [[ + {"text": f"🔄 Повторить ({attempt + 1})", "data": f"gemini:{btn_action}:{chat_id}:{base_message_id}:{attempt + 1}"}, + {"text": "👁 Запрос", "data": f"gemini:shreq:{is_regen_flag}:{chat_id}:{base_message_id}:{attempt + 1}"} + ]] if impersonation_mode: logger.error(f"Gauto error: {error_text}") - elif call: await call.edit(error_text, reply_markup=None) - elif status_msg: await utils.answer(status_msg, error_text) + elif call: await call.edit(error_text, reply_markup=error_buttons) + elif status_msg: await utils.answer(status_msg, error_text, reply_markup=error_buttons) return None if impersonation_mode else "" @loader.command() @@ -656,6 +991,84 @@ class Gemini(loader.Module): use_url_context=use_url_context, display_prompt=clean_args or None ) + @loader.command() + async def gask(self, message: Message): + """[текст или reply] — быстрый вопрос без сохранения в память.""" + clean_args = utils.get_args_raw(message) + if not clean_args and not await message.get_reply_message(): + return await utils.answer(message, self.strings["gask_no_prompt"]) + status_msg = await utils.answer(message, self.strings["processing"]) + status_msg = await self.client.get_messages(status_msg.chat_id, ids=status_msg.id) + parts, warnings = await self._prepare_parts(message, custom_text=clean_args) + if warnings and status_msg: + try: await status_msg.edit(f"{status_msg.text}\n\n" + "\n".join(warnings)) + except: pass + if not parts: + return await utils.answer(status_msg, self.strings["no_prompt_or_media"]) + await self._send_to_gemini( + message=message, + parts=parts, + status_msg=status_msg, + display_prompt=clean_args or None, + ephemeral=True, + ) + + @loader.command() + async def gmusic(self, message: Message): + """<промпт> — сгенерировать музыку/аудио через Gemini Lyria.""" + args = utils.get_args_raw(message) + if not args: + return await utils.answer(message, "🎵 Введите промпт для генерации музыки.\nПример: .gmusic веселая мелодия на гитаре") + m = await utils.answer(message, "🎵 Генерация аудио...") + keys = self._get_sorted_keys() + if not keys: + return await utils.answer(m, self.strings["all_keys_exhausted"].format(len(self.api_keys))) + audio_bytes = None + lyrics_text = "" + last_error = None + for key in keys: + try: + client = genai.Client(api_key=key) + interaction = await client.aio.interactions.create( + model="lyria-3-clip-preview", + input=args, + ) + for output in getattr(interaction, "outputs", []) or []: + if getattr(output, "type", None) == "audio" and getattr(output, "data", None): + audio_bytes = base64.b64decode(output.data) + elif getattr(output, "type", None) == "text" and getattr(output, "text", None): + lyrics_text = output.text + if audio_bytes: + break + raise ValueError("Модель не вернула аудио-данные.") + except Exception as e: + err_str = str(e).lower() + if any(x in err_str for x in ("429", "quota", "exhausted")): + self._set_key_cooldown(key, self._extract_retry_delay_seconds(str(e), 3600)) + self.key_model_map[key] = 0 + self.db.set(self.strings["name"], DB_KEY_MAP_KEY, self.key_model_map) + elif any(x in err_str for x in ("api key not valid", "api_key_invalid", "permission_denied", "client application")) and "model" not in err_str: + self._set_key_cooldown(key, 86400 * 365) + self.key_model_map[key] = -1 + self.db.set(self.strings["name"], DB_KEY_MAP_KEY, self.key_model_map) + last_error = e + continue + if not audio_bytes: + return await utils.answer(m, f"❌ Ошибка генерации музыки: {utils.escape_html(str(last_error or 'Не удалось получить аудио'))}") + out = io.BytesIO(audio_bytes) + out.name = f"gemini_music_{uuid.uuid4().hex[:6]}.mp3" + caption = f"🎵 Gemini Music (Lyria)\n📜 {utils.escape_html(args[:100])}" + if lyrics_text: + caption += f"\n\n🎤 Текст:\n
{utils.escape_html(lyrics_text[:800])}
" + await self.client.send_file( + utils.get_chat_id(message), + out, + caption=caption, + reply_to=message.id, + voice=True, + ) + await m.delete() + @loader.command() async def gimg(self, message: Message): """<промпт> [реплай на фото] — Генерация/Редактирование изображений через Gemini.""" @@ -775,46 +1188,15 @@ class Gemini(loader.Module): f"ВОПРОС ПОЛЬЗОВАТЕЛЯ: \"{user_prompt}\"\n\n" f"ИСТОРИЯ ЧАТА:\n---\n{chat_log}\n---" ) - try: - response_text = None - max_retries = len(self.api_keys) - analysis_config = types.GenerateContentConfig( - temperature=self.config["temperature"], - safety_settings=[ - types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"), - types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"), - types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"), - types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"), - ] - ) - proxy_config = self._get_proxy_config() - for i in range(max_retries): - key = self.api_keys[(self.current_api_key_index + i) % max_retries] - try: - async with httpx.AsyncClient(proxies=proxy_config) if proxy_config else httpx.AsyncClient() as http_client: - client = genai.Client(api_key=key, http_client=http_client) - resp = await client.aio.models.generate_content( - model=self.config["model_name"], - contents=full_prompt, - config=analysis_config - ) - if resp.text: - response_text = resp.text - self.current_api_key_index = (self.current_api_key_index + i) % max_retries - break - except Exception: continue - if not response_text: raise RuntimeError("Failed to generate answer (all keys or error).") - header = self.strings["gch_result_caption_from_chat"].format(count, chat_name) - response_html = self._markdown_to_html(response_text) - text_to_send = f"{header}\n\nQ:
{utils.escape_html(user_prompt)}
\n\nGemini:\n{self._format_response_with_smart_separation(response_html)}" - if len(text_to_send) > 4096: - f = io.BytesIO(response_text.encode('utf-8')) - await status_msg.delete() - await message.reply(file=f, caption=f"📝 {header}") - else: - await utils.answer(status_msg, text_to_send) - except Exception as e: - await utils.answer(status_msg, self._handle_error(e)) + header = self.strings["gch_result_caption_from_chat"].format(count, chat_name) + full_prompt = f"{header}\n\n{full_prompt}" + await self._send_to_gemini( + message=message, + parts=[types.Part(text=full_prompt)], + status_msg=status_msg, + display_prompt=f"{count} сообщений: {user_prompt}", + ephemeral=True, + ) @loader.command() async def gprompt(self, message: Message): @@ -893,9 +1275,17 @@ class Gemini(loader.Module): @loader.command() async def gclear(self, message: Message): - """[auto] — очистить память в чате. auto для памяти gauto.""" + """[global/auto] — очистить память в чате. auto для памяти gauto.""" args = utils.get_args_raw(message).lower() chat_id = utils.get_chat_id(message) + if args == "global": + if "global_context" in self.conversations: + del self.conversations["global_context"] + self._save_history_sync(False) + await utils.answer(message, self.strings["memory_cleared_global"]) + else: + await utils.answer(message, self.strings["gres_no_global"]) + return if args == "auto": if str(chat_id) in self.gauto_conversations: self._clear_history(chat_id, gauto=True) @@ -903,12 +1293,13 @@ class Gemini(loader.Module): else: await utils.answer(message, self.strings["no_gauto_memory_to_clear"]) return - if str(chat_id) in self.conversations: - self._clear_history(chat_id) + hist_key = "global_context" if self.config["global_memory"] else str(chat_id) + if hist_key in self.conversations: + self._clear_history(hist_key) keys_to_del =[k for k, v in self.pager_cache.items() if v.get("chat_id") == chat_id] for k in keys_to_del: del self.pager_cache[k] if keys_to_del: self.db.set(self.strings["name"], DB_PAGER_CACHE_KEY, self.pager_cache) - await utils.answer(message, self.strings["memory_cleared"]) + await utils.answer(message, self.strings["memory_cleared_global"] if hist_key == "global_context" else self.strings["memory_cleared"]) else: await utils.answer(message, self.strings["no_memory_to_clear"]) @@ -975,7 +1366,7 @@ class Gemini(loader.Module): """[N] — удалить последние N пар сообщений из памяти.""" try: n = int(utils.get_args_raw(message) or 1) except: n = 1 - cid = utils.get_chat_id(message) + cid = "global_context" if self.config["global_memory"] else utils.get_chat_id(message) hist = self._get_structured_history(cid) if n > 0 and len(hist) >= n*2: self.conversations[str(cid)] = hist[:-n*2] @@ -1123,7 +1514,7 @@ class Gemini(loader.Module): """[слово] — Поиск в памяти текущего чата по ключевому слову или фразе.""" q = utils.get_args_raw(message).lower() if not q: return await utils.answer(message, "Укажите слово для поиска.") - cid = utils.get_chat_id(message) + cid = "global_context" if self.config["global_memory"] else utils.get_chat_id(message) hist = self._get_structured_history(cid) found = [f"{e['role']}: {e.get('content','')[:200]}" for e in hist if q in str(e.get('content','')).lower()] if not found: await utils.answer(message, "Ничего не найдено.") @@ -1133,19 +1524,22 @@ class Gemini(loader.Module): async def gmemoff(self, message: Message): """— Отключить память в этом чате""" self.memory_disabled_chats.add(str(utils.get_chat_id(message))) + self.db.set(self.strings["name"], DB_MEMORY_DISABLED_KEY, list(self.memory_disabled_chats)) await utils.answer(message, "Память в этом чате отключена.") @loader.command() async def gmemon(self, message: Message): """— Включить память в этом чате""" self.memory_disabled_chats.discard(str(utils.get_chat_id(message))) + self.db.set(self.strings["name"], DB_MEMORY_DISABLED_KEY, list(self.memory_disabled_chats)) await utils.answer(message, "Память в этом чате включена.") @loader.command() async def gmemshow(self, message: Message): """[auto] — Показать память чата (до 20 последних запросов). auto для gauto.""" - gauto = "auto" in utils.get_args_raw(message) - cid = utils.get_chat_id(message) + args = utils.get_args_raw(message).lower() + gauto = "auto" in args + cid = "global_context" if ("global" in args or (self.config["global_memory"] and not gauto)) else utils.get_chat_id(message) hist = self._get_structured_history(cid, gauto=gauto) if not hist: return await utils.answer(message, "Память пуста.") out = [] @@ -1156,87 +1550,112 @@ class Gemini(loader.Module): elif role == 'model': out.append(f"Gemini: {content}") await utils.answer(message, "
" + "\n".join(out) + "
") + @loader.command() + async def gprovider(self, message: Message): + """[gemini/openrouter] — сменить провайдера API.""" + args = utils.get_args_raw(message).strip().lower() + if not args: + provider = self._normalize_provider_name() + effective = self._resolve_effective_model(provider, self.config["model_name"], [], "") + return await utils.answer( + message, + self.strings["gprovider_current"].format(self._provider_label(provider), utils.escape_html(effective)), + ) + provider = self._normalize_provider_name(args) + if provider not in ("google", "openrouter"): + return await utils.answer(message, self.strings["gprovider_usage"]) + prev = self._normalize_provider_name() + self._remember_provider_model(prev, self.config["model_name"], manual=not self.config["auto_model"]) + self.config["provider"] = provider + restored = self._restore_provider_model(provider) + await utils.answer(message, self.strings["gprovider_set"].format(self._provider_label(provider), utils.escape_html(restored))) + + @loader.command() + async def gprofile(self, message: Message): + """[auto|balanced|fast|reasoning|coding|vision|manual] — профиль авто-подбора модели.""" + args = utils.get_args_raw(message).strip().lower() + provider = self._normalize_provider_name() + if not args: + effective = self._resolve_effective_model(provider, self.config["model_name"], [], "") + return await utils.answer( + message, + "🧭 Профиль авто-модели\n" + f"• Текущий: {utils.escape_html(str(self.config['model_profile']))}\n" + f"• Auto: {'on' if self.config['auto_model'] else 'off'}\n" + f"• Провайдер: {self._provider_label(provider)}\n" + f"• Сейчас выберет: {utils.escape_html(effective)}\n\n" + f"{self.strings['gprofile_usage']}", + ) + if args not in MODEL_PROFILE_CHOICES: + return await utils.answer(message, self.strings["gprofile_usage"]) + self.config["model_profile"] = args + self.config["auto_model"] = args != "manual" + effective = self._resolve_effective_model(provider, self.config["model_name"], [], "") + self._remember_provider_model(provider, effective, manual=args == "manual") + await utils.answer(message, self.strings["gprofile_set"].format(utils.escape_html(args), utils.escape_html(effective))) + @loader.command() async def gmodel(self, message: Message): """[model] [-s] — Узнать/сменить модель. -s — список. Авто-проверка совместимости.""" - args_raw = utils.get_args_raw(message).strip().lower() - args_list = args_raw.split() - is_list_request = "-s" in args_list - provider = self.config["provider"] - if is_list_request: + args_raw = utils.get_args_raw(message).strip() + args = args_raw.lower() + provider = self._normalize_provider_name() + if args in ("-s", "--s", "s", "list"): status_msg = await utils.answer(message, self.strings["processing"]) try: - if provider == "openrouter": - api_key = self.config["Openrouter_api_key"] - if not api_key: return await utils.answer(status_msg, self.strings['no_api_key_Openrouter']) - async with aiohttp.ClientSession() as session: - async with session.get( - "https://openrouter.ai/api/v1/models", - headers={"Authorization": f"Bearer {api_key}"} - ) as resp: - if resp.status != 200: raise ValueError(f"HTTP {resp.status}") - data = await resp.json() - models_data = data.get("data",[]) - models_data.sort(key=lambda x: x["id"]) - top_list = [] - other_list = [] - favs =["google/gemini-2.0-flash-001", "openai/gpt-4o", "anthropic/claude-3.5-sonnet", "deepseek/deepseek-r1"] - for m in models_data: - mid = m["id"] - line = f"• {mid}" - if mid in favs: top_list.append(line) - elif any(x in mid for x in ["gemini", "gpt", "claude", "deepseek", "llama"]): other_list.append(line) - text = self.strings.get("gmodel_list_title_Openrouter", "📋 Models:") + "\n" + "\n".join(top_list) + "\n\n" - text += "\n".join(other_list[:50]) - if len(other_list) > 50: text += f"\n\n...и еще {len(other_list)-50} моделей." - file = io.BytesIO(text.encode("utf-8")) - await self.client.send_file(message.chat_id, file=file, caption="📋 OpenRouter Models", reply_to=message.id) - await status_msg.delete() - else: - if not self.api_keys: return await utils.answer(status_msg, self.strings['no_api_key']) - client = genai.Client(api_key=self.api_keys[self.current_api_key_index]) - models = await asyncio.to_thread(client.models.list) - txt = "\n".join([f"• {m.name.split('/')[-1]}" for m in models]) - f = io.BytesIO((self.strings["gmodel_list_title"] + "\n" + txt).encode('utf-8')) - f.name = "models_list.txt" - await self.client.send_file(message.chat_id, file=f, caption="📋 Список доступных моделей", reply_to=message.id) - await status_msg.delete() + await self._show_provider_model_catalog(status_msg, provider) except Exception as e: await utils.answer(status_msg, self.strings["gmodel_list_error"].format(self._handle_error(e))) return if not args_raw: - return await utils.answer(message, f"🔮 Провайдер: {provider}\n🧠 Модель: {self.config['model_name']}") + effective = self._resolve_effective_model(provider, self.config["model_name"], [], "") + return await utils.answer( + message, + f"🔮 Провайдер: {self._provider_label(provider)}\n" + f"🧠 Модель в конфиге: {utils.escape_html(str(self.config['model_name']))}\n" + f"🎯 Эффективная модель: {utils.escape_html(effective)}\n" + f"🧭 Профиль: {utils.escape_html(str(self.config['model_profile']))}" + ) self.config["model_name"] = args_raw + self.config["model_profile"] = "manual" + self.config["auto_model"] = False + self._remember_provider_model(provider, args_raw, manual=True) warning = "" - if provider == "google" and ("/" in args_raw or any(x in args_raw for x in["gpt", "claude", "deepseek", "llama"])): + if not self._model_matches_provider(args_raw, provider): warning = ( - "\n\n⚠️ Конфликт настроек!\n" - f"Вы установили модель {args_raw}, но провайдер остался Google.\n" - "Смените провайдера командой:\n.cfg gemini provider openrouter" + "\n\n⚠️ Возможна несовместимость.\n" + f"Модель {utils.escape_html(args_raw)} может не поддерживаться провайдером {self._provider_label(provider)}.\n" + "Если не работает, смените провайдера: .gprovider" ) - elif provider == "openrouter" and "/" not in args_raw and "gemini" in args_raw: - warning = ( - "\n\n⚠️ Совет: Для OpenRouter лучше использовать полные ID.\n" - f"Например: google/{args_raw}" - ) - await utils.answer(message, f"✅ Модель установлена: {args_raw}{warning}") + await utils.answer(message, f"✅ Модель установлена: {utils.escape_html(args_raw)}\n🧭 Авто-подбор переключен в manual. Вернуть: .gprofile auto{warning}") @loader.command() async def gres(self, message: Message): - """[auto] — Очистить ВСЮ память. auto для всей памяти gauto.""" - if utils.get_args_raw(message) == "auto": + """[global/auto] — Очистить ВСЮ память. auto для всей памяти gauto.""" + args = utils.get_args_raw(message).lower() + if args == "global": + if "global_context" in self.conversations: + del self.conversations["global_context"] + self._save_history_sync(False) + await utils.answer(message, self.strings["gres_global_cleared"]) + else: + await utils.answer(message, self.strings["gres_no_global"]) + return + if args == "auto": if not self.gauto_conversations: return await utils.answer(message, self.strings["no_gauto_memory_to_fully_clear"]) n = len(self.gauto_conversations) self.gauto_conversations.clear() self._save_history_sync(True) await utils.answer(message, self.strings["gauto_memory_fully_cleared"].format(n)) - else: - if not self.conversations: return await utils.answer(message, self.strings["no_memory_to_fully_clear"]) - n = len(self.conversations) - self.conversations.clear() + elif not args: + keys_to_delete = [k for k in self.conversations.keys() if k != "global_context"] + if not keys_to_delete: return await utils.answer(message, self.strings["no_memory_to_fully_clear"]) + for key in keys_to_delete: + del self.conversations[key] self._save_history_sync(False) - await utils.answer(message, self.strings["memory_fully_cleared"].format(n)) - + await utils.answer(message, self.strings["memory_fully_cleared"].format(len(keys_to_delete))) + else: + await utils.answer(message, self.strings["gres_usage"]) @loader.callback_handler() async def gemini_callback_handler(self, call: InlineCall): @@ -1270,9 +1689,10 @@ class Gemini(loader.Module): page = int(parts[3]) await self._render_page(uid, page, call) return - if action == "regen": + if action in ("regen", "regen_att"): chat_id = int(parts[2]) msg_id = int(parts[3]) + attempt = int(parts[4]) if action == "regen_att" and len(parts) > 4 else 1 key = f"{chat_id}:{msg_id}" last_request_tuple = self.last_requests.get(key) if not last_request_tuple: @@ -1280,7 +1700,10 @@ class Gemini(loader.Module): return last_parts, display_prompt = last_request_tuple use_url_context = bool(re.search(r'https?://\S+', display_prompt or "")) - await call.edit(f"⌛️ Регенерация...", reply_markup=None) + await call.edit( + f"⌛️ Регенерация (попытка {attempt})..." if attempt > 1 else f"⌛️ Регенерация...", + reply_markup=None, + ) await self._send_to_gemini( message=msg_id, parts=last_parts, @@ -1288,7 +1711,49 @@ class Gemini(loader.Module): call=call, chat_id_override=chat_id, use_url_context=use_url_context, - display_prompt=display_prompt + display_prompt=display_prompt, + attempt=attempt, + ) + return + if action == "retry": + chat_id = int(parts[2]) + msg_id = int(parts[3]) + attempt = int(parts[4]) if len(parts) > 4 else 1 + key = f"{chat_id}:{msg_id}" + last_request_tuple = self.last_requests.get(key) + if not last_request_tuple: + await call.answer(self.strings["no_last_request"], show_alert=True) + return + last_parts, display_prompt = last_request_tuple + use_url_context = bool(re.search(r'https?://\S+', display_prompt or "")) + await call.edit(f"⌛️ Обработка (попытка {attempt})...", reply_markup=None) + await self._send_to_gemini( + message=msg_id, + parts=last_parts, + regeneration=False, + call=call, + chat_id_override=chat_id, + use_url_context=use_url_context, + display_prompt=display_prompt, + attempt=attempt, + is_retry=True, + ) + return + if action == "shreq": + is_regen_flag = parts[2] + chat_id = int(parts[3]) + msg_id = int(parts[4]) + attempt = int(parts[5]) if len(parts) > 5 else 1 + key = f"{chat_id}:{msg_id}" + last_request_tuple = self.last_requests.get(key) + if not last_request_tuple: + await call.answer(self.strings["no_last_request"], show_alert=True) + return + _, display_prompt = last_request_tuple + btn_action = "regen_att" if is_regen_flag == "1" else "retry" + await call.edit( + f"📝 Ваш запрос:\n{utils.escape_html(display_prompt)}", + reply_markup=[[{"text": f"🔄 Повторить ({attempt})", "data": f"gemini:{btn_action}:{chat_id}:{msg_id}:{attempt}"}]], ) return @@ -1664,8 +2129,9 @@ class Gemini(loader.Module): except Exception as e: logger.warning(f"Ошибка удаления сообщения: {e}") async def _clear_callback(self, call: InlineCall, chat_id: int): - self._clear_history(chat_id, gauto=False) - await call.edit(self.strings["memory_cleared"], reply_markup=None) + hist_key = "global_context" if self.config["global_memory"] else chat_id + self._clear_history(hist_key, gauto=False) + await call.edit(self.strings["memory_cleared_global"] if hist_key == "global_context" else self.strings["memory_cleared"], reply_markup=None) async def _scan_keys(self, force=False): """ @@ -1714,13 +2180,18 @@ class Gemini(loader.Module): def _get_sorted_keys(self): valid_keys = [] + now = time.time() for key in self.api_keys: + if self.key_cooldowns.get(str(key), 0) > now: + continue if key not in self.key_model_map: - if not self.key_model_map: valid_keys.append((key, 0, random.random())) + valid_keys.append((key, 0, random.random())) continue tier = self.key_model_map[key] + if tier == -1: + continue valid_keys.append((key, tier, random.random())) - valid_keys.sort(key=lambda x: (x[1], x[2])) + valid_keys.sort(key=lambda x: (-x[1], x[2])) return [item[0] for item in valid_keys] async def _call_google_rest(self, model_name: str, prompt: str, input_image_bytes=None): @@ -1772,42 +2243,164 @@ class Gemini(loader.Module): return out.getvalue() except: return img_bytes + async def _get_provider_model_catalog(self, provider: str) -> list: + provider = self._normalize_provider_name(provider) + if provider == "openrouter": + api_key = next(iter(self._get_openrouter_keys()), "") + if api_key: + try: + async with aiohttp.ClientSession() as session: + async with session.get( + "https://openrouter.ai/api/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 200: + data = await resp.json() + models = sorted({m.get("id") for m in data.get("data", []) if m.get("id")}) + filtered = [ + model for model in models + if any(token in model.lower() for token in ("gemini", "claude", "gpt", "deepseek", "qwen")) + ] + return filtered or models + except Exception: + pass + return self._provider_curated_models(provider) + if provider == "google": + if self.api_keys: + try: + client = genai.Client(api_key=self.api_keys[self.current_api_key_index % len(self.api_keys)]) + models = await asyncio.to_thread(client.models.list) + listed = sorted({m.name.split("/")[-1] for m in models if getattr(m, "name", None)}) + if listed: + return listed + except Exception: + pass + return self._provider_curated_models(provider) + return self._provider_curated_models(provider) + + async def _show_provider_model_catalog(self, entity, provider: str): + provider = self._normalize_provider_name(provider) + models = await self._get_provider_model_catalog(provider) + if not models: + raise ValueError(self.strings["gmodel_no_models"]) + profiles = self._provider_profile_models(provider) + profile_index = {} + for profile_name, profile_model in profiles.items(): + profile_index.setdefault(profile_model, []).append(profile_name) + lines = [ + f"📋 {self._provider_label(provider)} Models", + f"🧭 Профиль: {utils.escape_html(str(self.config['model_profile']))} · Auto: {'on' if self.config['auto_model'] else 'off'}", + "", + ] + current = str(self.config["model_name"] or "") + for model in models[:300]: + marker = "✓" if model == current else "•" + tags = ", ".join(profile_index.get(model, [])) + suffix = f" {utils.escape_html(tags)}" if tags else "" + lines.append(f"{marker} {utils.escape_html(model)}{suffix}") + if len(models) > 300: + lines.append(f"\n...и еще {len(models) - 300} моделей.") + text = "\n".join(lines) + if len(text) <= 3800: + await utils.answer(entity, text) + return + chunks = self._paginate_text(text, 3400) + uid = uuid.uuid4().hex[:6] + self.pager_cache[uid] = { + "chunks": chunks, + "total": len(chunks), + "header": "", + "chat_id": getattr(entity, "chat_id", 0), + "msg_id": getattr(entity, "id", None), + } + self.db.set(self.strings["name"], DB_PAGER_CACHE_KEY, self.pager_cache) + await self._render_page(uid, 0, entity) + async def _send_to_Openrouter_api(self, model, messages, temperature): - """Отправка запроса в OpenRouter (OpenAI format)""" - api_key = self.config["Openrouter_api_key"] - if not api_key: + """Отправка запроса в OpenRouter (OpenAI format) с ротацией ключей.""" + keys = self._get_openrouter_keys() + if not keys: raise ValueError("Не указан OpenRouter API Key! Установите его в .cfg") url = "https://openrouter.ai/api/v1/chat/completions" - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - "HTTP-Referer": "https://github.com/SenkoGuardian", - "X-Title": "Gemini Module for Heroku Telegram-userbot", - } - payload = { - "model": model, - "messages": messages, - "temperature": min(temperature, 1.0) - } + now = time.time() + last_error = None async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers, json=payload, timeout=GEMINI_TIMEOUT) as resp: - text = await resp.text() - if resp.status != 200: + for api_key in keys: + cd_key = f"openrouter:{api_key}" + if self.key_cooldowns.get(cd_key, 0) > now: + continue + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/SenkoGuardian", + "X-Title": "Gemini Module for Heroku Telegram-userbot", + } + payload = { + "model": model, + "messages": messages, + "temperature": min(float(temperature), 2.0), + "max_tokens": 4096, + } + for attempt in range(2): try: - err_json = json.loads(text) - err_msg = err_json.get('error', {}).get('message', text) - except: - err_msg = text - raise ConnectionError(f"OpenRouter API Error {resp.status}: {err_msg}") - try: - result = json.loads(text) - except json.JSONDecodeError: - raise ValueError(f"OpenRouter вернул не JSON: {text[:100]}...") - if "choices" not in result or not result["choices"]: - if "error" in result: - raise ValueError(f"OpenRouter Logic Error: {result['error']}") - raise ValueError(f"Пустой ответ (нет 'choices'). Raw: {text}") - return result["choices"][0]["message"]["content"] + async with session.post( + url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=GEMINI_TIMEOUT), + ) as resp: + text = await resp.text() + if resp.status == 402 and attempt == 0: + try: + err_msg = json.loads(text).get("error", {}).get("message", text) + match = re.search(r"can only afford (\d+)", err_msg) + if match: + payload["max_tokens"] = max(1, int(match.group(1))) + continue + except Exception: + pass + if resp.status == 429: + self._set_key_cooldown(cd_key, 3600) + last_error = ConnectionError(f"OpenRouter 429: лимит ключа ...{api_key[-6:]}") + break + if resp.status in (401, 403): + self._set_key_cooldown(cd_key, 86400 * 365) + try: + err_msg = json.loads(text).get("error", {}).get("message", text) + except Exception: + err_msg = text + last_error = ConnectionError(f"OpenRouter API Error {resp.status}: {err_msg}") + break + if resp.status != 200: + try: + err_msg = json.loads(text).get("error", {}).get("message", text) + except Exception: + err_msg = text + last_error = ConnectionError(f"OpenRouter API Error {resp.status}: {err_msg}") + break + try: + result = json.loads(text) + except json.JSONDecodeError: + raise ValueError(f"OpenRouter вернул не JSON: {text[:200]}...") + if "choices" not in result or not result["choices"]: + if "error" in result: + raise ValueError(f"OpenRouter Logic Error: {result['error']}") + raise ValueError(f"Пустой ответ (нет 'choices'). Raw: {text[:200]}") + message_obj = result["choices"][0].get("message") or {} + content = message_obj.get("content") + if isinstance(content, list): + content = "\n".join(str(part.get("text") or part.get("content") or "") for part in content if isinstance(part, dict)).strip() + content = str(content or "").strip() + if not content: + raise ValueError(f"Пустой ответ OpenRouter. Raw: {text[:200]}") + return content, (result.get("usage") or {}) + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + last_error = e + break + if last_error: + continue + raise last_error or ValueError(f"Все OpenRouter ключи ({len(keys)}) исчерпаны или недоступны") def _convert_google_history_to_openai(self, history: list, system_prompt: str) -> list: """Конвертирует историю из формата Google в формат OpenAI.""" @@ -1827,7 +2420,10 @@ class Gemini(loader.Module): messages.append({"role": role, "content": content}) return messages - def _is_memory_enabled(self, chat_id: str) -> bool: return chat_id not in self.memory_disabled_chats - def _disable_memory(self, chat_id: int): self.memory_disabled_chats.add(str(chat_id)) - def _enable_memory(self, chat_id: int): self.memory_disabled_chats.discard(str(chat_id)) + def _disable_memory(self, chat_id: int): + self.memory_disabled_chats.add(str(chat_id)) + self.db.set(self.strings["name"], DB_MEMORY_DISABLED_KEY, list(self.memory_disabled_chats)) + def _enable_memory(self, chat_id: int): + self.memory_disabled_chats.discard(str(chat_id)) + self.db.set(self.strings["name"], DB_MEMORY_DISABLED_KEY, list(self.memory_disabled_chats)) diff --git a/fiksofficial/python-modules/createpacks.py b/fiksofficial/python-modules/createpacks.py index 114622c..c1421b7 100644 --- a/fiksofficial/python-modules/createpacks.py +++ b/fiksofficial/python-modules/createpacks.py @@ -21,6 +21,7 @@ import random import string import asyncio import logging +import re from PIL import Image, UnidentifiedImageError from telethon.tl.functions.stickers import CreateStickerSetRequest @@ -38,11 +39,12 @@ except AttributeError: logger = logging.getLogger(__name__) - +STATIC_STICKER_LIMIT = 120 +EMOJI_LIMIT = 200 async def process_to_webp(input_path: str, output_path: str, size: int = 512) -> bool: try: - is_video = input_path.lower().endswith(('.mp4', '.webm', '.mov')) or b'ftyp' in open(input_path, 'rb').read(32) + is_video = input_path.lower().endswith((".mp4", ".webm", ".mov")) or b"ftyp" in open(input_path, "rb").read(32) if is_video: cap = cv2.VideoCapture(input_path) success, frame = cap.read() @@ -87,7 +89,7 @@ async def process_to_webp(input_path: str, output_path: str, size: int = 512) -> async def process_to_png(input_path: str, output_path: str, size: int = 100) -> bool: try: - is_video = input_path.lower().endswith(('.mp4', '.webm', '.mov')) or b'ftyp' in open(input_path, 'rb').read(32) + is_video = input_path.lower().endswith((".mp4", ".webm", ".mov")) or b"ftyp" in open(input_path, "rb").read(32) if is_video: cap = cv2.VideoCapture(input_path) success, frame = cap.read() @@ -137,8 +139,10 @@ class CreatePacks(loader.Module): "processing": "[CreatePacks] Collecting avatars of participants...", "no_avatars": "[CreatePacks] No members with avatars", "no_valid": "[CreatePacks] Could not process any avatars", - "done_pack": "[CreatePacks] Sticker pack is ready:\n[CreatePacks] Open: here", - "done_emoji_pack": "[CreatePacks] Emoji pack is ready:\n[CreatePacks] Open: here", + "done_pack": "[CreatePacks] Sticker pack is ready:\n[CreatePacks] Open: here", + "done_packs": "[CreatePacks] Sticker packs are ready:\n{}", + "done_emoji_pack": "[CreatePacks] Emoji pack is ready:\n[CreatePacks] Open: here", + "done_emoji_packs": "[CreatePacks] Emoji packs are ready:\n{}", "already": "[CreatePacks] A sticker pack with this name already exists.", "emoji_processing": "[CreatePacks] Creating emoji pack from avatars...", "emoji_no_emoji": "[CreatePacks] No emoji specified — using", @@ -149,8 +153,10 @@ class CreatePacks(loader.Module): "processing": "[CreatePacks] Собираю аватарки участников...", "no_avatars": "[CreatePacks] Нет участников с аватарками", "no_valid": "[CreatePacks] Не удалось обработать ни одну аватарку", - "done_pack": "[CreatePacks] Стикерпак готов:\n[CreatePacks] Открыть: здесь", - "done_emoji_pack": "[CreatePacks] Эмодзи-пак готов:\n[CreatePacks] Открыть: здесь", + "done_pack": "[CreatePacks] Стикерпак готов:\n[CreatePacks] Открыть: здесь", + "done_packs": "[CreatePacks] Стикерпаки готовы:\n{}", + "done_emoji_pack": "[CreatePacks] Эмодзи-пак готов:\n[CreatePacks] Открыть: здесь", + "done_emoji_packs": "[CreatePacks] Эмодзи-паки готовы:\n{}", "already": "[CreatePacks] Стикерпак с таким именем уже существует", "emoji_processing": "[CreatePacks] Создаю эмодзи-пак из аватаров...", "emoji_no_emoji": "[CreatePacks] Эмодзи не указан — используется", @@ -169,10 +175,6 @@ class CreatePacks(loader.Module): if len(users) >= 100: break - if not users: - shutil.rmtree(tmp_dir, ignore_errors=True) - return [], tmp_dir - processed = [] process_func = process_to_webp if format == "webp" else process_to_png @@ -224,6 +226,43 @@ class CreatePacks(loader.Module): return processed, tmp_dir + async def _create_sticker_pack(self, message, stickers_to_add, is_emoji_pack: bool, pack_number: int = 1, emoji: str = "🖼️"): + random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) + short_name = f"pack_{random_str}_by_fcreate" + + chat = await message.get_chat() + chat_title = getattr(chat, 'title', 'Chat') + + title_prefix = "Ava" if not is_emoji_pack else "Emoji" + full_title = f"{chat_title} {title_prefix} #{pack_number}" + + try: + await self._client(CreateStickerSetRequest( + user_id="me", + title=full_title, + short_name=short_name, + stickers=stickers_to_add, + emojis=is_emoji_pack + )) + return short_name, full_title + except PackShortNameOccupiedError: + random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=12)) + short_name = f"pack_{random_str}_by_fcreate" + try: + await self._client(CreateStickerSetRequest( + user_id="me", + title=full_title, + short_name=short_name, + stickers=stickers_to_add, + emojis=is_emoji_pack + )) + return short_name, full_title + except: + return "already_exists", None + except Exception as e: + logger.error(f"Error creating pack: {e}") + return None, None + @loader.command( ru_doc="- Создать стикерпак из аватаров в группе", only_groups=True @@ -236,11 +275,7 @@ class CreatePacks(loader.Module): if not files: return await message.edit(self.strings("no_avatars")) - tag = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4)) - short_name = f"f{abs(message.chat_id)}_{tag}_by_fcreateavatars" - title = f"AvaPack {tag}" - - stickers = [] + all_stickers = [] for path in files: try: await asyncio.sleep(0.3) @@ -248,42 +283,42 @@ class CreatePacks(loader.Module): msg = await self._client.send_file("me", file, force_document=True) doc = msg.document await self._client.delete_messages("me", msg.id) - stickers.append(InputStickerSetItem( + all_stickers.append(InputStickerSetItem( document=InputDocument(doc.id, doc.access_hash, doc.file_reference), emoji="🖼️" )) except Exception as e: logger.error(f"Sticker loading error {path}: {e}") continue - - if not stickers: + + if not all_stickers: shutil.rmtree(tmp_dir, ignore_errors=True) return await message.edit(self.strings("no_valid")) - try: - await self._client(CreateStickerSetRequest( - user_id="me", - title=title, - short_name=short_name, - stickers=stickers - )) - await message.edit(self.strings("done_pack").format(short_name)) - except PackShortNameOccupiedError: - await message.edit(self.strings("already")) - except Exception as e: - error_details = f"❌ Ошибка создания стикерпака:\n{type(e).__name__}: {e}\n" - error_details += f"Пак: {short_name}\nСтикеров: {len(stickers)}\n" - if files: - error_details += f"Последний файл: {files[-1]}\n" - try: - error_details += f"Размер: {Image.open(files[-1]).size}\n" - error_details += f"Вес: {os.path.getsize(files[-1])} байт" - except: - pass - await message.edit(error_details) - logger.exception("Error creating sticker pack") - finally: - shutil.rmtree(tmp_dir, ignore_errors=True) + created_packs_links = [] + pack_number = 1 + for i in range(0, len(all_stickers), STATIC_STICKER_LIMIT): + current_pack_stickers = all_stickers[i : i + STATIC_STICKER_LIMIT] + short_name, full_title = await self._create_sticker_pack(message, current_pack_stickers, False, pack_number) + if short_name == "already_exists": + await message.edit(self.strings("already")) + shutil.rmtree(tmp_dir, ignore_errors=True) + return + elif short_name: + created_packs_links.append(f"{full_title}") + pack_number += 1 + + if created_packs_links: + if len(created_packs_links) == 1: + # Extract short name for the single link format + sn = created_packs_links[0].split('/')[-1].split("'")[0] + await message.edit(self.strings("done_pack").format(sn)) + else: + await message.edit(self.strings("done_packs").format("\n".join(created_packs_links))) + else: + await message.edit(self.strings("no_valid")) + + shutil.rmtree(tmp_dir, ignore_errors=True) @loader.command( ru_doc="[эмодзи] - Создать эмодзи-пак из всех аватаров", @@ -303,11 +338,7 @@ class CreatePacks(loader.Module): if not files: return await message.edit(self.strings("no_avatars")) - tag = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4)) - short_name = f"f{abs(message.chat_id)}_{tag}_by_fcreateemojis" - title = f"EmojiPack {tag}" - - stickers = [] + all_emojis = [] for path in files: try: await asyncio.sleep(0.3) @@ -315,7 +346,7 @@ class CreatePacks(loader.Module): msg = await self._client.send_file("me", file, force_document=True) doc = msg.document await self._client.delete_messages("me", msg.id) - stickers.append(InputStickerSetItem( + all_emojis.append(InputStickerSetItem( document=InputDocument(doc.id, doc.access_hash, doc.file_reference), emoji=emoji )) @@ -323,32 +354,30 @@ class CreatePacks(loader.Module): logger.error(f"Error loading emoji {path}: {e}") continue - if not stickers: + if not all_emojis: shutil.rmtree(tmp_dir, ignore_errors=True) return await message.edit(self.strings("no_valid")) - try: - await self._client(CreateStickerSetRequest( - user_id="me", - title=title, - short_name=short_name, - stickers=stickers, - emojis=True - )) - await message.edit(self.strings("done_emoji_pack").format(short_name)) - except PackShortNameOccupiedError: - await message.edit(self.strings("already")) - except Exception as e: - error_details = f"❌ Ошибка создания эмодзи-пака:\n{type(e).__name__}: {e}\n" - error_details += f"Пак: {short_name}\nСмайликов: {len(stickers)}\n" - if files: - error_details += f"Последний файл: {files[-1]}\n" - try: - error_details += f"Размер: {Image.open(files[-1]).size}\n" - error_details += f"Вес: {os.path.getsize(files[-1])} байт" - except: - pass - await message.edit(error_details) - logger.exception("Error creating emoji pack") - finally: - shutil.rmtree(tmp_dir, ignore_errors=True) \ No newline at end of file + created_packs_links = [] + pack_number = 1 + for i in range(0, len(all_emojis), EMOJI_LIMIT): + current_pack_emojis = all_emojis[i : i + EMOJI_LIMIT] + short_name, full_title = await self._create_sticker_pack(message, current_pack_emojis, True, pack_number, emoji) + if short_name == "already_exists": + await message.edit(self.strings("already")) + shutil.rmtree(tmp_dir, ignore_errors=True) + return + elif short_name: + created_packs_links.append(f"{full_title}") + pack_number += 1 + + if created_packs_links: + if len(created_packs_links) == 1: + sn = created_packs_links[0].split('/')[-1].split("'")[0] + await message.edit(self.strings("done_emoji_pack").format(sn)) + else: + await message.edit(self.strings("done_emoji_packs").format("\n".join(created_packs_links))) + else: + await message.edit(self.strings("no_valid")) + + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/mead0wsss/mead0wsMods/gifts.json b/mead0wsss/mead0wsMods/gifts.json index a7c4456..16f161c 100644 --- a/mead0wsss/mead0wsMods/gifts.json +++ b/mead0wsss/mead0wsMods/gifts.json @@ -57,6 +57,12 @@ "gifts":[ {"id": 5969796561943660080, "emoji": "🧸", "name": "Пасхальный мишка", "price": 50} ] + }, + "may_1th": { + "name": "🛠 1 Мая", + "gifts":[ + {"id": 6026193266406327981, "emoji": "🧸", "name": "1 Мая мишка", "price": 50} + ] } } } From 74dfe4caf8b20972d27c64dad744f948212eee0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 3 May 2026 02:11:26 +0000 Subject: [PATCH 2/2] Updated modules.json after parse 2026-05-03 02:11:26 --- modules.json | 104 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/modules.json b/modules.json index efd65a8..d585e42 100644 --- a/modules.json +++ b/modules.json @@ -3981,16 +3981,20 @@ "processing": "[CreatePacks] Collecting avatars of participants...", "no_avatars": "[CreatePacks] No members with avatars", "no_valid": "[CreatePacks] Could not process any avatars", - "done_pack": "[CreatePacks] Sticker pack is ready:\n[CreatePacks] Open: here", - "done_emoji_pack": "[CreatePacks] Emoji pack is ready:\n[CreatePacks] Open: here", + "done_pack": "[CreatePacks] Sticker pack is ready:\n[CreatePacks] Open: here", + "done_packs": "[CreatePacks] Sticker packs are ready:\n{}", + "done_emoji_pack": "[CreatePacks] Emoji pack is ready:\n[CreatePacks] Open: here", + "done_emoji_packs": "[CreatePacks] Emoji packs are ready:\n{}", "already": "[CreatePacks] A sticker pack with this name already exists.", "emoji_processing": "[CreatePacks] Creating emoji pack from avatars...", "emoji_no_emoji": "[CreatePacks] No emoji specified — using", "processing_ru": "[CreatePacks] Собираю аватарки участников...", "no_avatars_ru": "[CreatePacks] Нет участников с аватарками", "no_valid_ru": "[CreatePacks] Не удалось обработать ни одну аватарку", - "done_pack_ru": "[CreatePacks] Стикерпак готов:\n[CreatePacks] Открыть: здесь", - "done_emoji_pack_ru": "[CreatePacks] Эмодзи-пак готов:\n[CreatePacks] Открыть: здесь", + "done_pack_ru": "[CreatePacks] Стикерпак готов:\n[CreatePacks] Открыть: здесь", + "done_packs_ru": "[CreatePacks] Стикерпаки готовы:\n{}", + "done_emoji_pack_ru": "[CreatePacks] Эмодзи-пак готов:\n[CreatePacks] Открыть: здесь", + "done_emoji_packs_ru": "[CreatePacks] Эмодзи-паки готовы:\n{}", "already_ru": "[CreatePacks] Стикерпак с таким именем уже существует", "emoji_processing_ru": "[CreatePacks] Создаю эмодзи-пак из аватаров...", "emoji_no_emoji_ru": "[CreatePacks] Эмодзи не указан — используется" @@ -12019,6 +12023,12 @@ { "g": "[текст или reply] — спросить у Gemini. Может анализировать ссылки." }, + { + "gask": "[текст или reply] — быстрый вопрос без сохранения в память." + }, + { + "gmusic": "<промпт> — сгенерировать музыку/аудио через Gemini Lyria." + }, { "gimg": "<промпт> [реплай на фото] — Генерация/Редактирование изображений через Gemini." }, @@ -12038,7 +12048,7 @@ "gautochats": "— Показать чаты с активным режимом авто-ответа." }, { - "gclear": "[auto] — очистить память в чате. auto для памяти gauto." + "gclear": "[global/auto] — очистить память в чате. auto для памяти gauto." }, { "gpresets": " — Управление пресетами (профилями)." @@ -12067,11 +12077,17 @@ { "gmemshow": "[auto] — Показать память чата (до 20 последних запросов). auto для gauto." }, + { + "gprovider": "[gemini/openrouter] — сменить провайдера API." + }, + { + "gprofile": "[auto|balanced|fast|reasoning|coding|vision|manual] — профиль авто-подбора модели." + }, { "gmodel": "[model] [-s] — Узнать/сменить модель. -s — список. Авто-проверка совместимости." }, { - "gres": "[auto] — Очистить ВСЮ память. auto для всей памяти gauto." + "gres": "[global/auto] — Очистить ВСЮ память. auto для всей памяти gauto." } ], "new_commands": [ @@ -12088,6 +12104,32 @@ "is_inline_handler": false, "decorators": [] }, + { + "name": "gask", + "original_name": "gask", + "description": { + "default": "[текст или reply] — быстрый вопрос без сохранения в память." + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + }, + { + "name": "gmusic", + "original_name": "gmusic", + "description": { + "default": "<промпт> — сгенерировать музыку/аудио через Gemini Lyria." + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + }, { "name": "gimg", "original_name": "gimg", @@ -12170,7 +12212,7 @@ "name": "gclear", "original_name": "gclear", "description": { - "default": "[auto] — очистить память в чате. auto для памяти gauto." + "default": "[global/auto] — очистить память в чате. auto для памяти gauto." }, "cmd_names": {}, "aliases": [], @@ -12296,6 +12338,32 @@ "is_inline_handler": false, "decorators": [] }, + { + "name": "gprovider", + "original_name": "gprovider", + "description": { + "default": "[gemini/openrouter] — сменить провайдера API." + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + }, + { + "name": "gprofile", + "original_name": "gprofile", + "description": { + "default": "[auto|balanced|fast|reasoning|coding|vision|manual] — профиль авто-подбора модели." + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + }, { "name": "gmodel", "original_name": "gmodel", @@ -12313,7 +12381,7 @@ "name": "gres", "original_name": "gres", "description": { - "default": "[auto] — Очистить ВСЮ память. auto для всей памяти gauto." + "default": "[global/auto] — Очистить ВСЮ память. auto для всей памяти gauto." }, "cmd_names": {}, "aliases": [], @@ -12377,10 +12445,10 @@ }, "commands": [ { - "chatcopy": " [start_id:final_id] [-n] [-dmc] [--now] [--media|--photo_video|--docs|--text] — Добавить задачу в очередь. --now: начать сразу, без полного подсчёта." + "chatcopy": " [start_id:final_id] [-n] [-dmc] [--now] [--itopic 1] [-theme123] [--media|--photo_video|--docs|--text] — Добавить задачу в очередь." }, { - "ccwatch": " [start_id:final_id] [-n] [-dmc][--media|--photo_video|--docs|--text] — Наблюдение за чатом" + "ccwatch": " [start_id:final_id] [-n] [-dmc] [--itopic 1] [-theme123] [--media|--photo_video|--docs|--text] — Наблюдение за чатом" }, { "cchelp": "— Подробная документация по модулю ChatCopy" @@ -12397,7 +12465,7 @@ "name": "chatcopy", "original_name": "chatcopy", "description": { - "default": " [start_id:final_id] [-n] [-dmc] [--now] [--media|--photo_video|--docs|--text] — Добавить задачу в очередь. --now: начать сразу, без полного подсчёта." + "default": " [start_id:final_id] [-n] [-dmc] [--now] [--itopic 1] [-theme123] [--media|--photo_video|--docs|--text] — Добавить задачу в очередь." }, "cmd_names": {}, "aliases": [], @@ -12410,7 +12478,7 @@ "name": "ccwatch", "original_name": "ccwatch", "description": { - "default": " [start_id:final_id] [-n] [-dmc][--media|--photo_video|--docs|--text] — Наблюдение за чатом" + "default": " [start_id:final_id] [-n] [-dmc] [--itopic 1] [-theme123] [--media|--photo_video|--docs|--text] — Наблюдение за чатом" }, "cmd_names": {}, "aliases": [], @@ -12465,8 +12533,9 @@ "cfg_batch": "Размер пачки сообщений (1-100)", "cfg_delay": "Задержка ОТПРАВКИ между пачками (сек)", "cfg_flood_buffer": "Дополнительное время к FloodWait (сек)", - "copy_start_prem": "🚀 ChatCopy: Запуск копирования\n\nИсточник: {src}\n\nЦель: {dest}\n\n⚙️ Режим: {mode}\n🔢 Старт с ID: {start_id}\n👤 Без автора: {no_auth}\n💬 Без подписей: {no_capt}\n📎 Фильтр: {filter_type}\n📦 Всего сообщений: {total_msgs}\n⏱ Оценка времени: {estimated_time}\n\nЗадача добавлена в очередь. Позиция: {position}", - "copy_start_no_prem": "🚀 ChatCopy: Запуск копирования\n\nИсточник: {src}\n⏬⏬⏬⏬\nЦель: {dest}\n\n⚙️ Режим: {mode}\n🔢 Старт с ID: {start_id}\n👤 Без автора: {no_auth}\n💬 Без подписей: {no_capt}\n📎 Фильтр: {filter_type}\n📦 Всего сообщений: {total_msgs}\n⏱ Оценка времени: {estimated_time}\n\nЗадача добавлена в очередь. Позиция: {position}", + "cfg_timezone": "Часовой пояс для времени в статусах (UTC offset, например 3 для MSK)", + "copy_start_prem": "🚀 ChatCopy: Запуск копирования\n\nИсточник: {src}\n\nЦель: {dest}\n\n⚙️ Режим: {mode}\n🔢 Старт с ID: {start_id}\n👤 Без автора: {no_auth}\n💬 Без подписей: {no_capt}\n📎 Фильтр: {filter_type}\n🚫 Игнор топиков: {ignored_topics}\n📦 Всего сообщений: {total_msgs}\n⏱ Оценка времени: {estimated_time}\n\nЗадача добавлена в очередь. Позиция: {position}", + "copy_start_no_prem": "🚀 ChatCopy: Запуск копирования\n\nИсточник: {src}\n⏬⏬⏬⏬\nЦель: {dest}\n\n⚙️ Режим: {mode}\n🔢 Старт с ID: {start_id}\n👤 Без автора: {no_auth}\n💬 Без подписей: {no_capt}\n📎 Фильтр: {filter_type}\n🚫 Игнор топиков: {ignored_topics}\n📦 Всего сообщений: {total_msgs}\n⏱ Оценка времени: {estimated_time}\n\nЗадача добавлена в очередь. Позиция: {position}", "copy_done_detailed_prem": " Задача выполнена\n
{src} → {dest}\nБез автора: {no_auth}\nБез подписей: {no_capt}\nСтарт с ID: {start_id}\nРежим: {mode}\nФильтр: {filter_type}
\n Перенесено сообщений: {count} \n⏱ Длительность: {duration}\n⚡ Средняя скорость: {avg_speed} сообщений/мин{flood_info}", "copy_done_detailed_no_prem": "Задача выполнена\n
{src} → {dest}\nБез автора: {no_auth}\nБез подписей: {no_capt}\nСтарт с ID: {start_id}\nРежим: {mode}\nФильтр: {filter_type}
\n✔️ Перенесено сообщений: {count} ✔️\n⏱ Длительность: {duration}\n⚡ Средняя скорость: {avg_speed} сообщений/мин{flood_info}", "flood_wait_notice": "⏸ FloodWait\n📊 Задержка: {minutes}m {seconds}s\n🕐 Возобновление: {resume_time}\n📨 Переслано: {count} сообщений\n⏳ Осталось: {remaining} сообщений\n⚡ Скорость: {speed} сообщений/мин", @@ -12485,8 +12554,9 @@ "forum_enable_failed": "❌ Не удалось включить топики в {chat}. Нужны права администратора.", "forum_not_channel": "❌ {chat} не является каналом/группой", "err_ent": "❌ Ошибка: Чат не найден или нет доступа.", - "args_err": "❌ Синтаксис: .chatcopy [start_id:final_id] [-n] [-dmc] [--now] [--media|--photo_video|--docs|--text]", - "watch_added": "👀 Наблюдение активировано\nID: {src_id}\n{src} -> {dest}\nРежим топиков: {topics}\nБез подписей: {no_capt}\nФильтр: {filter_type}", + "args_err": "❌ Синтаксис: .chatcopy [start_id:final_id] [-n] [-dmc] [--now] [--itopic 1|\"Имя\"] [-theme123] [--media|--photo_video|--docs|--text]", + "watch_added": "👀 Наблюдение активировано\nID: {src_id}\n{src} -> {dest}\nРежим топиков: {topics}\nБез подписей: {no_capt}\nФильтр: {filter_type}\nИгнор топиков: {ignored}", + "copy_restricted": "❌ Источник защищён запретом копирования/пересылки Telegram.\n\nМодуль остановлен до добавления в очередь: скрытый обход этой защиты не выполняется. Используй источник, где копирование разрешено, или отключи защиту в своём чате.", "queue_wait": "⏳ Задача в очереди... ({pos})", "topic_created": "📂 Создан топик: {title}", "topic_error": "❌ Ошибка создания топика: {error}", @@ -78702,6 +78772,6 @@ }, "meta": { "total_modules": 995, - "generated_at": "2026-04-24T17:42:27.132273" + "generated_at": "2026-05-03T02:11:26.683417" } } \ No newline at end of file