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}
+ ]
}
}
}