\n"
+ "✔️ Перенесено сообщений: {count} ✔️\n"
+ "⏱ Длительность: {duration}\n"
+ "⚡ Средняя скорость: {avg_speed} сообщений/мин"
+ "{flood_info}"
+ ),
+ "flood_wait_notice": (
+ "⏸ FloodWait\n"
+ "📊 Задержка:{minutes}m {seconds}s\n"
+ "🕐 Возобновление:{resume_time}\n"
+ "📨 Переслано:{count} сообщений\n"
+ "⏳ Осталось:{remaining} сообщений\n"
+ "⚡ Скорость:{speed} сообщений/мин"
+ ),
+ "panel_summary": "📊 ChatCopy Status\n\n🔄 Активная: {active}\n⏳ В очереди: {queue_len}\n👀 Слежка: {watching_count}\n⏱ Последний FW: {last_flood}",
+ "panel_task_running": "{name}\n├ 📦 {count}/{total} сообщений\n├ ⚡ {speed}/мин | 📊 {progress}%\n├ ⏱ Прошло: {elapsed} | Осталось: {eta}\n└ 🕐 Начало: {start_time} | Окончание: {end_time}",
+ "panel_task_paused": "{name}\n├ ⏸ На паузе (FW: {flood_time})\n├ 📦 {count}/{total} сообщений\n├ ⚡ {speed}/мин\n└ 🕐 Продолжение: {resume_time}",
+ "btn_stop": "🛑 Стоп",
+ "btn_pause": "⏸ Пауза",
+ "btn_resume": "▶️ Продолжить",
+ "btn_back": "🔙 Назад",
+ "btn_tasks": "📋 Очередь задач",
+ "btn_watch": "👀 Слежка",
+ "btn_settings": "⚙️ Настройки",
+ "btn_stats": "📊 Статистика",
+ "forum_enabled": "✅ Топики включены в {chat}",
+ "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}",
+ "queue_wait": "⏳ Задача в очереди... ({pos})",
+ "topic_created": "📂 Создан топик: {title}",
+ "topic_error": "❌ Ошибка создания топика: {error}",
+ "task_stopped": "🛑 Задача остановлена\nПереслано: {count} сообщений{flood_info}",
+ "stats_title": "📊 Статистика ChatCopy\n\n",
+ "stats_total": "Всего задач: {total}\nЗавершено: {completed}\nОстановлено: {stopped}\nFloodWait'ов: {floods}",
+ "task_list_header": "📋 Очередь задач ({total})\n\nНажми на номер для подробностей\n\n",
+ "task_item_compact_running": "▶️{num}. {src} → {dest} ({progress}%)",
+ "task_item_compact_queued": "⏳{num}. {src} → {dest} (через {wait})",
+ "task_item_compact_paused": "⚠️{num}. {src} → {dest} (FW)",
+ "task_item_compact_completed": "✅{num}. {src} → {dest}",
+ "task_item_compact_error": "❌{num}. {src} → {dest}",
+ "task_detail_running": "▶️ Задача #{num}\n\n{src} → {dest}\n├ Статус: Выполняется\n├ Прогресс: {current}/{total} ({progress}%)\n├ Скорость: {speed}/мин\n├ Прошло: {elapsed}\n├ Осталось: {eta_left}\n├ Начато: {start_time}\n├ Окончание: {end_time}\n└ Позиция: {position}",
+ "task_detail_queued": "⏳ Задача #{num}\n\n{src} → {dest}\n├ Статус: В очереди\n├ Позиция: {position}\n├ Сообщений: ~{total}\n├ Ожидание старта: {eta_start}\n└ Примерное время работы: {estimated_duration}",
+ "task_detail_paused": "⚠️ Задача #{num}\n\n{src} → {dest}\n├ Статус: Пауза (FloodWait)\n├ Прогресс: {current}/{total} ({progress}%)\n├ FloodWait'ов: {flood_count}\n├ Время ожидания: {flood_time}\n├ Продолжение: {resume_time}\n├ Скорость до паузы: {speed}/мин\n└ Осталось сообщений: {remaining}",
+ "task_detail_completed": "✅ Задача #{num}\n\n{src} → {dest}\n├ Статус: Завершена\n├ Переслано: {count} сообщений\n├ Длительность: {duration}\n├ Средняя скорость: {avg_speed}/мин\n├ Завершено: {end_time}\n└ FloodWait'ов: {floods}",
+ "task_detail_error": "❌ Задача #{num}\n\n{src} → {dest}\n├ Статус: Ошибка\n└ Попробуйте перезапустить",
+ "no_tasks": "Нет активных задач",
+ "preparing_prem": "💫Подготовка к копированию. Подсчитываем (да, вручную!) кол-во медиа, это может занять время...",
+ "preparing_no_prem": "⌛️ Подготовка к копированию. Подсчитываем кол-во медиа, это может занять время...",
+ }
+
+ def __init__(self):
+ self._tasks = []
+ self.config = loader.ModuleConfig(
+ 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)),
+ )
+ self.queue = asyncio.Queue()
+ self.dump_queue = asyncio.Queue()
+ self.watcher_buffer = {}
+ self.watcher_flush_tasks = {}
+ self.watchlist = {}
+ self.active_dumps = {}
+ self.last_watched = {}
+ self.last_processed_ids = {}
+ self.current_dump_task = None
+ self.is_premium = False
+ self.topic_mapping = {}
+ self.topic_info_cache = {}
+ self.task_stats = {}
+ self.last_flood_info = {"time": None, "duration": 0, "task": None, "resume_at": None}
+ self.task_queue = []
+ self.task_history = []
+ self.current_task_index = 0
+ self.is_processing_queue = False
+ self.task_progress_cache = {}
+ self.global_speed_history = []
+ self.avg_speed_history = []
+ self._queue_lock = asyncio.Lock()
+ self._task_counter = 0
+
+ async def client_ready(self, client, db):
+ global _cc_client, _cc_log_channel, _cc_log_topic_id
+ self.client = client
+ self.db = db
+ self.watchlist = self.db.get("ChatCopy", "watchlist", {})
+ self.last_processed_ids = self.db.get("ChatCopy", "last_processed_ids", {})
+ self.topic_mapping = self.db.get("ChatCopy", "topic_mapping", {})
+ self.task_stats = self.db.get("ChatCopy", "task_stats", {})
+ self.task_queue = self.db.get("ChatCopy", "persistent_queue", [])
+ for task in self.task_queue:
+ task['status'] = 'queued'
+ me = await client.get_me()
+ self.is_premium = getattr(me, 'premium', False)
+ try:
+ asset_channel = self._db.get("heroku.forums", "channel_id", 0)
+ if asset_channel:
+ notif_topic = await utils.asset_forum_topic(
+ self._client,
+ self._db,
+ asset_channel,
+ "ChatCopy Logs",
+ description="ChatCopy module activity logs (warnings & errors).",
+ icon_emoji_id=5372917041193828849,
+ )
+ _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)
+ except Exception as _e:
+ logger.debug("ChatCopy log topic setup skipped: %s", _e)
+ self._tasks.extend([
+ asyncio.create_task(self.worker()),
+ asyncio.create_task(self.dump_worker()),
+ asyncio.create_task(self._catch_up_on_restart())
+ ])
+ if not self.task_queue:
+ return
+ logger.info(f"Возобновление {len(self.task_queue)} задач из очереди после перезапуска.")
+ for task in self.task_queue:
+ try:
+ src = await self.client.get_entity(task['src_id'])
+ dest = await self.client.get_entity(task['dest_id'])
+ class FakeMsg:
+ id = None
+ chat_id = task.get('status_chat_id')
+ async def edit(self, *args, **kwargs): pass
+ await self.dump_queue.put({
+ "status_msg": FakeMsg(),
+ "src": src, "dest": dest,
+ "no_auth": task['no_author'], "no_captions": task['no_captions'],
+ "map_t": task.get('map_t', False), "f_src_t": task.get('f_src_t'),
+ "f_dest_t": task.get('f_dest_t'), "tid": task['tid'],
+ "min_id": task.get('last_processed_id', task.get('start_id', 0)),
+ "max_id": task.get('final_id', 0),
+ "filter_type": task['filter_type'], "src_name": task['src'],
+ "total_msgs": task['total_msgs'],
+ "restored_count": task.get('current', 0),
+ })
+ except Exception as e:
+ logger.error(f"Не удалось возобновить задачу {task.get('tid')}: {e}")
+
+ async def _resolve_arg(self, arg): # все виды (ну почти) ссылок как дадут id и прочее,
+ # работает если копировать сообщение в топике и в аргумент типа куда отправлять вставить.
+ extra = {}
+ entity = None
+ arg = str(arg).strip()
+ regex = r"(?:t\.me/|tg://resolve\?domain=|tg://openmessage\?user_id=)(?:c/)?([\w\d_]+)(?:/(\d+))?(?:/(\d+))?"
+ match = re.search(regex, arg)
+ if match:
+ identifier = match.group(1)
+ if match.group(2): extra['topic'] = int(match.group(2))
+ if match.group(3): extra['msg'] = int(match.group(3))
+ if identifier.isdigit():
+ for potential_id in [int(identifier), int(f"-100{identifier}")]:
+ try:
+ entity = await self.client.get_entity(potential_id)
+ if entity: break
+ except: continue
+ else:
+ try: entity = await self.client.get_entity(identifier)
+ except: 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
+ return entity, extra
+
+ def _get_normalized_id(self, entity): # что бы получать норм айди а не нечто, что бы копировка шла хорошо.
+ if not entity:
+ return "0"
+ 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:
+ return f"-100{eid}"
+ if not eid.startswith("-"):
+ return f"-100{eid}"
+ return eid
+ return "0"
+
+ def _is_forum(self, entity): # да, не спрашивайте.
+ if not isinstance(entity, Channel):
+ return False
+ if hasattr(entity, 'forum') and entity.forum:
+ return True
+ if hasattr(entity, 'flags') and entity.flags is not None:
+ return bool(entity.flags & (1 << 30))
+ return False
+
+ async def _ensure_forum_enabled(self, entity): # проверяет режим топиков у чата и пытается включить его, если он отключен (требуются права админа).
+ if not isinstance(entity, Channel):
+ return False
+ if self._is_forum(entity):
+ return True
+ try:
+ result = await self.client(functions.channels.ToggleForumRequest(channel=entity, enabled=True))
+ if result:
+ updated_entity = await self.client.get_entity(entity.id)
+ return self._is_forum(updated_entity)
+ return False
+ except errors.FloodWaitError as e:
+ await asyncio.sleep(e.seconds + self.config["flood_buffer"])
+ return await self._ensure_forum_enabled(entity)
+ except errors.ChatAdminRequiredError:
+ return False
+ except Exception:
+ return False
+
+ async def _get_topic_info(self, chat_entity, topic_id): #получаем инфо о топике для копирования
+ if not topic_id:
+ return None, None, None
+ cache_key = f"{chat_entity.id}_{topic_id}"
+ if cache_key in self.topic_info_cache:
+ return self.topic_info_cache[cache_key]
+ title, icon_emoji_id, icon_color = None, None, None
+ for attempt in range(3):
+ try:
+ result = await self.client(functions.messages.GetForumTopicsByIDRequest(peer=chat_entity, topics=[topic_id]))
+ if result and hasattr(result, 'topics') and result.topics:
+ for topic in result.topics:
+ if hasattr(topic, 'id') and topic.id == topic_id:
+ title = getattr(topic, 'title', None)
+ icon_emoji_id = getattr(topic, 'icon_emoji_id', None)
+ icon_color = getattr(topic, 'icon_color', None)
+ break
+ if title:
+ break
+ except errors.FloodWaitError as e:
+ await asyncio.sleep(e.seconds + self.config["flood_buffer"])
+ except Exception:
+ 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))
+ if result and hasattr(result, 'topics'):
+ for topic in result.topics:
+ if hasattr(topic, 'id') and topic.id == topic_id:
+ title = getattr(topic, 'title', None)
+ icon_emoji_id = getattr(topic, 'icon_emoji_id', None)
+ icon_color = getattr(topic, 'icon_color', None)
+ break
+ except Exception:
+ pass
+ if not title:
+ try:
+ async for msg in self.client.iter_messages(chat_entity, limit=1, reply_to=topic_id):
+ if msg and hasattr(msg, 'reply_to') and msg.reply_to:
+ title = getattr(msg.reply_to, 'forum_topic_title', None)
+ if not title and msg:
+ title = msg.text[:50] if msg.text else f"Topic {topic_id}"
+ break
+ except Exception:
+ pass
+ if not title:
+ title = f"Topic {topic_id}"
+ info = (title, icon_emoji_id, icon_color)
+ self.topic_info_cache[cache_key] = info
+ return info
+
+ async def _create_topic(self, dest_entity, title, src_topic_id=None, icon_emoji_id=None, icon_color=None): # создает топик
+ if not isinstance(dest_entity, Channel) or not self._is_forum(dest_entity):
+ return None
+ try:
+ random_id = random.randint(1, 2**63 - 1)
+ kwargs = {
+ "peer": dest_entity,
+ "title": title[:128] if len(title) > 128 else title,
+ "random_id": random_id
+ }
+ if icon_emoji_id:
+ kwargs["icon_emoji_id"] = icon_emoji_id
+ elif icon_color:
+ kwargs["icon_color"] = icon_color
+ else:
+ kwargs["icon_color"] = 0x6FB9F0
+ result = await self.client(functions.messages.CreateForumTopicRequest(**kwargs))
+ new_topic_id = None
+ if result:
+ if hasattr(result, 'updates'):
+ for update in result.updates:
+ if hasattr(update, 'message'):
+ msg = update.message
+ if hasattr(msg, 'action') and hasattr(msg.action, 'topic_id'):
+ new_topic_id = msg.action.topic_id
+ if hasattr(msg, 'reply_to') and msg.reply_to:
+ new_topic_id = getattr(msg.reply_to, 'reply_to_top_id', None) or getattr(msg.reply_to, 'reply_to_msg_id', None)
+ if new_topic_id:
+ break
+ if not new_topic_id and hasattr(result, 'messages') and result.messages:
+ for msg in result.messages:
+ if hasattr(msg, 'reply_to') and msg.reply_to:
+ new_topic_id = getattr(msg.reply_to, 'reply_to_top_id', None)
+ if new_topic_id:
+ 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))
+ if topics_result and hasattr(topics_result, 'topics'):
+ for topic in topics_result.topics:
+ if getattr(topic, 'title', '') == title:
+ new_topic_id = topic.id
+ break
+ return new_topic_id
+ except errors.FloodWaitError as e:
+ await asyncio.sleep(e.seconds + self.config["flood_buffer"])
+ return await self._create_topic(dest_entity, title, src_topic_id, icon_emoji_id, icon_color)
+ except errors.TopicDeletedError:
+ return None
+ except Exception:
+ return None
+
+ async def _ensure_topic_mapping(self, src_entity, dest_entity, src_topic_id): # копирует точ в точ топик.
+ if not src_topic_id:
+ return None
+ mapping_key = f"{src_entity.id}_{dest_entity.id}_{src_topic_id}"
+ if mapping_key in self.topic_mapping:
+ cached_id = self.topic_mapping[mapping_key]
+ try:
+ await self.client(functions.messages.GetForumTopicsByIDRequest(peer=dest_entity, topics=[cached_id]))
+ return cached_id
+ except Exception:
+ pass
+ 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}"
+ try:
+ offset_date = 0
+ offset_id = 0
+ offset_topic = 0
+ found_topic_id = None
+ for _ in range(5):
+ topics_result = await self.client(functions.messages.GetForumTopicsRequest(
+ peer=dest_entity, offset_date=offset_date, offset_id=offset_id, offset_topic=offset_topic, limit=100
+ ))
+ if not topics_result or not hasattr(topics_result, 'topics') or not topics_result.topics:
+ break
+ for topic in topics_result.topics:
+ if getattr(topic, 'title', '') == title:
+ if icon_emoji_id:
+ if getattr(topic, 'icon_emoji_id', None) == icon_emoji_id:
+ found_topic_id = topic.id
+ break
+ else:
+ found_topic_id = topic.id
+ break
+ if found_topic_id:
+ break
+ offset_topic = topics_result.topics[-1].id
+ if found_topic_id:
+ self.topic_mapping[mapping_key] = found_topic_id
+ self.db.set("ChatCopy", "topic_mapping", self.topic_mapping)
+ return found_topic_id
+ except Exception as e:
+ logger.error(f"Error checking existing topics: {e}")
+ for attempt in range(3):
+ new_topic_id = await self._create_topic(dest_entity, title, src_topic_id, icon_emoji_id, icon_color)
+ if new_topic_id:
+ self.topic_mapping[mapping_key] = new_topic_id
+ self.db.set("ChatCopy", "topic_mapping", self.topic_mapping)
+ return new_topic_id
+ await asyncio.sleep(5)
+ return None
+
+ async def on_unload(self):
+ """Остановка всех задач при выгрузке модуля"""
+ for task in self._tasks:
+ if not task.done(): task.cancel()
+ for tid in list(self.active_dumps.keys()):
+ self.active_dumps[tid]["status"] = "stopped"
+ if "cancel" in self.active_dumps[tid]: self.active_dumps[tid]["cancel"].set()
+ for t in self.watcher_flush_tasks.values(): t.cancel()
+
+ def _should_include_message(self, msg, filter_type): # handler типов сообщений. медиа, документ и прочее.
+ if filter_type == FILTER_ALL:
+ return True
+ has_photo = bool(msg.photo)
+ has_video = bool(msg.video)
+ has_video_note = bool(msg.video_note)
+ has_document = bool(msg.document)
+ has_voice = bool(msg.voice)
+ has_audio = bool(msg.audio)
+ has_sticker = bool(msg.sticker)
+ has_text = bool(msg.text and not msg.media)
+ is_gif = False
+ if has_document and not has_sticker and hasattr(msg.document, 'attributes'):
+ for attr in msg.document.attributes:
+ if isinstance(attr, types.DocumentAttributeAnimated):
+ is_gif = True
+ break
+ is_file_document = has_document and not (has_video or has_video_note or has_audio or has_voice or has_sticker or is_gif or has_photo)
+ if has_video and has_sticker:
+ has_video = False
+ if has_document and not has_photo and not has_sticker:
+ doc = msg.document
+ if hasattr(doc, 'mime_type'):
+ mime = doc.mime_type or ''
+ if mime.startswith('image/'):
+ has_photo = True
+ is_file_document = False
+ elif mime.startswith('video/') and not is_gif:
+ has_video = True
+ is_file_document = False
+ if filter_type == FILTER_MEDIA:
+ return has_photo or has_video or is_file_document
+ elif filter_type == FILTER_PHOTO_VIDEO:
+ return (has_photo or has_video) and not (has_sticker or is_gif)
+ elif filter_type == FILTER_DOCS:
+ return is_file_document
+ elif filter_type == FILTER_TEXT:
+ return has_text and not (has_photo or has_video or has_video_note or has_document or has_sticker or has_voice or has_audio or is_gif)
+ return True
+
+ async def _send_flood_notice(self, chat_id, seconds, count,
+ 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")
+ remaining = max(0, total_msgs - count)
+ self.last_flood_info = {
+ "time": datetime.now(MSK).strftime("%H:%M:%S"),
+ "duration": seconds,
+ "task": task_id,
+ "resume_at": resume_time
+ }
+ try:
+ await self.client.send_message(
+ chat_id,
+ self.strings["flood_wait_notice"].format(
+ minutes=minutes,
+ seconds=secs,
+ resume_time=resume_time,
+ count=count,
+ remaining=remaining,
+ speed=round(speed, 1)
+ )
+ )
+ except Exception:
+ pass
+
+ def _format_flood_stats(self, task_data): # формирует красивую строку со статистикой FloodWait для вывода в итоговом сообщении.
+ floods = task_data.get('flood_count', 0)
+ total_seconds = task_data.get('flood_total_seconds', 0)
+ if floods == 0:
+ return ""
+ hours = total_seconds // 3600
+ minutes = (total_seconds % 3600) // 60
+ if hours > 0:
+ time_str = f"{hours}h {minutes}m"
+ else:
+ time_str = f"{minutes}m"
+ return f"\n⏱ {floods} FloodWait (~{time_str})"
+
+ def _format_duration(self, seconds): # описание ниже
+ """Форматирует длительность в читаемый вид"""
+ if seconds < 60:
+ return f"{int(seconds)}с"
+ elif seconds < 3600:
+ return f"{int(seconds // 60)}м {int(seconds % 60)}с"
+ else:
+ hours = int(seconds // 3600)
+ mins = int((seconds % 3600) // 60)
+ return f"{hours}ч {mins}м"
+
+ 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):
+ 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":
+ return 0
+ filtered_messages = [msg for msg in messages if self._should_include_message(msg, filter_type)]
+ if not filtered_messages:
+ return 0
+ if map_topics and (not dest_entity or isinstance(dest_entity, (int, str))):
+ try:
+ dest_entity = await self.client.get_entity(dest_id)
+ except Exception:
+ map_topics = False
+ if map_topics and not src_entity:
+ try:
+ src_entity = await self.client.get_entity(filtered_messages[0].chat_id)
+ except Exception:
+ pass
+ msg_groups = {}
+ 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
+ key = src_topic_id if src_topic_id else "no_topic"
+ msg_groups.setdefault(key, []).append(msg)
+ total_sent = 0
+ delay = self.config["delay"]
+ if not isinstance(delay, int):
+ delay = 10
+ for src_topic_id, msgs in msg_groups.items():
+ 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":
+ break
+ target_topic = fixed_dest_topic
+ if map_topics and src_topic_id != "no_topic":
+ target_topic = await self._ensure_topic_mapping(src_entity, dest_entity, src_topic_id)
+ if not target_topic:
+ continue
+ 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
+ min_interval = 3
+ if time_since_last < min_interval:
+ extra_wait = min_interval - time_since_last
+ logger.debug(f"[{tid}] Дополнительная задержка для соблюдения интервала: {extra_wait:.1f}с")
+ await asyncio.sleep(extra_wait)
+ success = await self._raw_sender(msgs, dest_id, no_author, no_captions, target_topic, status_msg, tid)
+ if success:
+ total_sent += len(msgs)
+ if tid and tid in self.active_dumps:
+ self.active_dumps[tid]["last_successful_send"] = time.time()
+ await asyncio.sleep(delay)
+ return total_sent
+
+ async def worker(self): # воркер для Watcher'а
+ while True:
+ item = await self.queue.get()
+ try:
+ watch_cid = item.get("watch_cid")
+ if watch_cid and watch_cid not in self.watchlist:
+ logger.debug(f"Игнорируем сообщение для {watch_cid}, слежка была остановлена")
+ continue
+ result = await self._process_batch(**item)
+ if watch_cid and item.get("messages"):
+ last_msg = item["messages"][-1]
+ self.last_processed_ids[watch_cid] = last_msg.id
+ self.db.set("ChatCopy", "last_processed_ids", self.last_processed_ids)
+ except Exception as e:
+ logger.error(f"Worker error: {e}")
+ finally:
+ self.queue.task_done()
+
+ async def dump_worker(self):
+ """worker очереди, с последовательным выполнением задач""" # он типа очень умни и если добавить последовательно несколько чатов,
+ # то он не переключится а просто в очередь добавит
+ 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)
+ if idx is not None:
+ self.task_queue[idx]['status'] = 'running'
+ self.task_queue[idx]['start_time'] = datetime.now(MSK)
+ 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()
+ 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:
+ update_task.cancel()
+ if tid in self.active_dumps:
+ completed_task = self.active_dumps[tid].copy()
+ completed_task['tid'] = tid
+ completed_task['end_time'] = datetime.now(MSK)
+ 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
+ self.task_stats[tid] = {
+ 'completed_at': time.time() if completed_task.get('status') == 'completed' else None,
+ 'flood_count': completed_task.get('flood_count', 0),
+ 'flood_time': completed_task.get('flood_total_seconds', 0),
+ '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.current_dump_task = None
+ self.is_processing_queue = False
+ 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()
+
+ def _update_queue_positions(self): # описание ниже
+ """Обновляет позиции задач в очереди"""
+ queued_tasks = [t for t in self.task_queue if t['status'] == 'queued']
+ for i, task in enumerate(queued_tasks, 1):
+ task['position'] = i
+
+ async def _auto_update_status(self, tid, status_msg): # описание ниже
+ """Обновляет только внутренний кэш скорости без редактирования сообщения"""
+ while True:
+ try:
+ await asyncio.sleep(5)
+ if tid not in self.active_dumps:
+ break
+ task = self.active_dumps[tid]
+ status = task.get('status', 'unknown')
+ if status not in ['running', 'paused']:
+ continue
+ current = task.get('current', 0)
+ total = task.get('total_estimated', 0)
+ start_time = task.get('start_time', time.time())
+ elapsed = time.time() - start_time
+ now = time.time()
+ last_calc_time = task.get('_last_calc_time', now - 5)
+ last_calc_count = task.get('_last_calc_count', current)
+ delta_t = now - last_calc_time
+ delta_c = current - last_calc_count
+ if status == 'running':
+ if delta_t > 0:
+ inst_speed = (delta_c / delta_t) * 60
+ task['speed_samples'].append(inst_speed)
+ if len(task['speed_samples']) > 12:
+ task['speed_samples'].pop(0)
+ task['_last_calc_time'] = now
+ task['_last_calc_count'] = current
+ avg_speed = sum(task['speed_samples']) / len(task['speed_samples']) if task['speed_samples'] else 0
+ task['current_speed'] = avg_speed
+ if avg_speed > 0:
+ self.global_speed_history.append(avg_speed)
+ if len(self.global_speed_history) > 50:
+ self.global_speed_history.pop(0)
+ self.task_progress_cache[tid] = {
+ 'current': current,
+ 'speed': round(avg_speed, 1),
+ 'eta': self._calculate_eta(current, total, avg_speed),
+ 'progress': round((current / total * 100), 1) if total > 0 else 0,
+ 'elapsed': elapsed,
+ 'status': status
+ }
+ # прогресс идёт в логи через logger.info
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(f"Auto update error: {e}")
+
+ def _get_avg_speed(self): # описание ниже
+ """Получает среднюю скорость из глобальной истории"""
+ if not self.global_speed_history:
+ return 100
+ return sum(self.global_speed_history) / len(self.global_speed_history)
+
+ def _calculate_eta(self, current, total, speed_per_min): # описание ниже
+ """Расчёт оставшегося времени"""
+ if speed_per_min <= 0 or total <= 0:
+ return "∞"
+ remaining = total - current
+ minutes = remaining / speed_per_min
+ return self._format_duration(minutes * 60)
+
+ def _calculate_task_wait_time(self, target_position): # описание ниже
+ """Расчёт времени ожидания для задачи в очереди"""
+ avg_speed = self._get_avg_speed()
+ total_seconds = 0
+ for task in self.task_queue:
+ if task['position'] < target_position and task['status'] not in ['completed', 'stopped', 'error']:
+ remaining = task.get('total_msgs', 0) - task.get('current', 0)
+ if remaining > 0:
+ task_seconds = (remaining / avg_speed) * 60 if avg_speed > 0 else 3600
+ total_seconds += task_seconds
+ return self._format_duration(total_seconds)
+
+ def _estimate_duration(self, total_msgs): # описание ниже
+ """Оценка длительности задачи"""
+ avg_speed = self._get_avg_speed()
+ if avg_speed <= 0 or total_msgs <= 0:
+ return "∞"
+ minutes = total_msgs / avg_speed
+ return self._format_duration(minutes * 60)
+
+ def _calculate_end_time(self, start_time, total_msgs, speed_per_min=None): # описание ниже
+ """Расчёт времени окончания задачи"""
+ if speed_per_min is None:
+ speed_per_min = self._get_avg_speed()
+ if speed_per_min <= 0 or total_msgs <= 0:
+ return "∞"
+ minutes = total_msgs / speed_per_min
+ end_time = start_time + timedelta(minutes=minutes)
+ 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"""
+ try:
+ dest_peer = await self.client.get_input_entity(dest_id)
+ src_peer = await self.client.get_input_entity(messages[0].chat_id)
+ await self.client(functions.messages.ForwardMessagesRequest(
+ from_peer=src_peer, id=[m.id for m in messages],
+ to_peer=dest_peer, drop_author=no_author, top_msg_id=topic_id,
+ with_my_score=False, drop_media_captions=no_captions
+ ))
+ if tid and tid in self.active_dumps:
+ self.active_dumps[tid]["last_successful_send"] = time.time()
+ self.active_dumps[tid]["consecutive_floods"] = 0
+ return True
+ except errors.FloodWaitError as e:
+ wait_time = e.seconds if e.seconds is not None else 60
+ if tid and tid in self.active_dumps:
+ task = self.active_dumps[tid]
+ task["consecutive_floods"] = task.get("consecutive_floods", 0) + 1
+ task["flood_count"] = task.get("flood_count", 0) + 1
+ task["flood_total_seconds"] = task.get("flood_total_seconds", 0) + wait_time
+ task["current_flood_wait"] = wait_time
+ task["status"] = "paused"
+ task["flood_wait_until"] = time.time() + wait_time + self.config["flood_buffer"]
+ current_speed = task.get('current_speed', 0)
+ total_msgs = task.get('total_estimated', 0)
+ current_count = task.get('current', 0)
+ status_chat = task.get("status_chat_id")
+ if status_chat:
+ await self._send_flood_notice(status_chat, wait_time, current_count, tid, total_msgs, current_speed)
+ logger.warning(f"[{tid}] FloodWait: ждём {wait_time}с (запрошено Telegram) + {self.config['flood_buffer']}с буфер")
+ total_wait = wait_time + self.config["flood_buffer"]
+ waited = 0
+ check_interval = 5
+ while waited < total_wait:
+ if tid in self.active_dumps:
+ if self.active_dumps[tid].get("status") == "stopped":
+ logger.info(f"[{tid}] Задача остановлена во время ожидания FloodWait")
+ return False
+ await asyncio.sleep(min(check_interval, total_wait - waited))
+ waited += check_interval
+ if tid in self.active_dumps:
+ self.active_dumps[tid]["status"] = "running"
+ self.active_dumps[tid]["last_successful_send"] = time.time()
+ try:
+ await self.client(functions.messages.ForwardMessagesRequest(
+ from_peer=src_peer, id=[m.id for m in messages],
+ to_peer=dest_peer, drop_author=no_author, top_msg_id=topic_id,
+ with_my_score=False, drop_media_captions=no_captions
+ ))
+ if tid and tid in self.active_dumps:
+ self.active_dumps[tid]["last_successful_send"] = time.time()
+ self.active_dumps[tid]["consecutive_floods"] = 0
+ return True
+ except errors.FloodWaitError as e2:
+ logger.warning(f"[{tid}] Повторный FloodWait: ждём ещё {e2.seconds}с")
+ await asyncio.sleep(e2.seconds + self.config["flood_buffer"])
+ return False
+ return False
+ except Exception as e:
+ logger.error(f"[{tid}] Send Error: {e}")
+ return False
+
+ def _parse_filter(self, args): # все аргументы нужные цепляет
+ filter_type = FILTER_ALL
+ args_list = list(args)
+ for arg in args_list:
+ 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
+
+ def _get_filter_name(self, filter_type):
+ names = {
+ FILTER_ALL: "Все сообщения",
+ FILTER_MEDIA: "Только медиа",
+ FILTER_PHOTO_VIDEO: "Фото и видео",
+ FILTER_DOCS: "Документы",
+ FILTER_TEXT: "Текст",
+ }
+ return names.get(filter_type, "Неизвестно")
+
+ def _get_effective_batch_size(self) -> int:
+ """Returns the current batch_size from config, always fresh."""
+ val = self.config.get("batch_size", 100)
+ if isinstance(val, int) and 1 <= val <= 100:
+ return val
+ return 100
+
+ @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()
+ 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"]]
+ if len(clean_args) < 2:
+ return await utils.answer(message, self.strings["args_err"])
+ start_id = 0
+ final_id = 0
+ if len(clean_args) >= 3:
+ id_arg = clean_args[2]
+ if ":" in id_arg:
+ parts = id_arg.split(":")
+ if parts[0].isdigit():
+ start_id = int(parts[0])
+ if len(parts) > 1 and parts[1].isdigit():
+ final_id = int(parts[1])
+ elif id_arg.isdigit():
+ start_id = int(id_arg)
+ src, src_map = await self._resolve_arg(clean_args[0])
+ 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"])
+ self._task_counter += 1
+ tid = f"{src.id}_{dest.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:
+ forum_result = await self._ensure_forum_enabled(dest)
+ if forum_result:
+ dest = await self.client.get_entity(dest.id)
+ dest_is_forum = self._is_forum(dest)
+ if not dest_is_forum:
+ await asyncio.sleep(2)
+ dest = await self.client.get_entity(dest.id)
+ dest_is_forum = self._is_forum(dest)
+ if dest_is_forum:
+ logger.info("[%s] Режим топиков включён на dest %s", tid, getattr(dest, 'title', dest.id))
+ else:
+ logger.warning("[%s] _ensure_forum_enabled вернул True, но _is_forum всё ещё False для dest %s", tid, getattr(dest, 'title', dest.id))
+ else:
+ logger.warning("[%s] Не удалось включить топики на dest %s — копирование пойдёт без маппинга топиков", tid, getattr(dest, 'title', dest.id))
+ elif src_is_forum and dest_is_forum:
+ try:
+ dest = await self.client.get_entity(dest.id)
+ dest_is_forum = self._is_forum(dest)
+ except Exception:
+ pass
+ if src_is_forum and not dest_is_forum:
+ logger.warning("[%s] src — форум, dest — НЕ форум. Все сообщения пойдут в General!", tid)
+ prep_key = "preparing_prem" if self.is_premium else "preparing_no_prem"
+ status_msg = await utils.answer(message, self.strings[prep_key])
+ total_msgs = 0
+ f_src_t_for_count = src_map.get('topic')
+ if start_now:
+ try:
+ if f_src_t_for_count:
+ async for _ in self.client.iter_messages(
+ src,
+ reply_to=f_src_t_for_count,
+ min_id=start_id - 1 if start_id else 0,
+ max_id=final_id + 1 if final_id else 0,
+ ):
+ total_msgs += 1
+ if total_msgs > 150000: break
+ else:
+ result = await self.client(functions.messages.GetHistoryRequest(
+ peer=src,
+ offset_id=0,
+ offset_date=None,
+ add_offset=0,
+ limit=1,
+ max_id=final_id + 1 if final_id else 0,
+ min_id=start_id - 1 if start_id else 0,
+ hash=0,
+ ))
+ total_msgs = getattr(result, 'count', 0) or 0
+ except Exception as e:
+ logger.warning(f"Count failed for --now: {e}")
+ total_msgs = 0
+ else:
+ try:
+ iter_kwargs = {
+ "min_id": start_id - 1 if start_id else 0,
+ "max_id": final_id + 1 if final_id else 0,
+ }
+ if f_src_t_for_count:
+ iter_kwargs["reply_to"] = f_src_t_for_count
+ async for _ in self.client.iter_messages(src, **iter_kwargs):
+ total_msgs += 1
+ if total_msgs > 150000: break
+ except Exception as e:
+ logger.error(f"Ошибка при подсчете сообщений: {e}")
+ total_msgs = -1
+ src_name = getattr(src, 'title', src.id)
+ dest_name = getattr(dest, 'title', dest.id)
+ async with self._queue_lock:
+ queue_position = len([t for t in self.task_queue if t['status'] == 'queued']) + 1
+ estimated_duration = self._estimate_duration(total_msgs)
+ mode_str = "🗂️ Топики (Auto)" if src_is_forum else "Обычный"
+ no_auth_str = "Да" if no_author else "Нет"
+ no_capt_str = "Да" if no_captions else "Нет"
+ 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(),
+ '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,
+ '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,
+ }
+ self.task_queue.append(task_info)
+ self._save_tasks()
+ filter_name = self._get_filter_name(filter_type)
+ 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,
+ total_msgs=total_msgs if total_msgs > -1 else "∞ (ошибка подсчета)",
+ estimated_time=estimated_duration, position=queue_position
+ ))
+ await self.dump_queue.put({
+ "status_msg": status_msg, "src": src, "dest": dest,
+ "no_auth": no_author, "no_captions": no_captions,
+ "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,
+ "src_name": src_name, "queue_position": queue_position, "total_msgs": total_msgs if total_msgs > -1 else 0,
+ "restored_count": 0,
+ })
+
+ def _parse_duration(self, duration_str): # описание ниже
+ """Парсит строку длительности в секунды"""
+ if duration_str == "∞":
+ return 3600
+ total = 0
+ parts = duration_str.split()
+ for part in parts:
+ if 'ч' in part:
+ total += int(part.replace('ч', '')) * 3600
+ elif 'ч' in part and 'м' in part:
+ pass
+ elif 'м' in part and 'с' not in part:
+ total += int(part.replace('м', '')) * 60
+ elif 'м' in part and 'с' in part:
+ mins_secs = part.replace('м', '').replace('с', '').split()
+ if len(mins_secs) >= 1:
+ total += int(mins_secs[0]) * 60
+ if len(mins_secs) >= 2:
+ total += int(mins_secs[1])
+ elif 'с' in part:
+ total += int(part.replace('с', ''))
+ elif part.isdigit():
+ total += int(part)
+ return total if total > 0 else 0
+
+ @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()
+ 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"]]
+ if len(clean_args) < 2:
+ return await utils.answer(message, self.strings["args_err"])
+ start_id = 0
+ final_id = 0
+ if len(clean_args) >= 3:
+ id_arg = clean_args[2]
+ if ":" in id_arg:
+ parts = id_arg.split(":")
+ if parts[0].isdigit(): start_id = int(parts[0])
+ if len(parts) > 1 and parts[1].isdigit(): final_id = int(parts[1])
+ elif id_arg.isdigit():
+ start_id = int(id_arg)
+ src, src_map = await self._resolve_arg(clean_args[0])
+ 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"])
+ src_is_forum = self._is_forum(src)
+ dest_is_forum = self._is_forum(dest)
+ if src_is_forum and not dest_is_forum:
+ forum_result = await self._ensure_forum_enabled(dest)
+ if forum_result:
+ await utils.answer(message, self.strings["forum_enabled"].format(chat=utils.escape_html(getattr(dest, 'title', dest.id))))
+ 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:
+ dest_id = dest.id
+ if start_id > 0:
+ self.last_processed_ids[cid] = start_id - 1
+ elif cid not in self.last_processed_ids:
+ 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
+ }
+ 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)
+ 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
+ )
+ if start_id > 0 or final_id > 0:
+ range_str = "Все новые"
+ if start_id > 0 and final_id > 0: range_str = f"с {start_id} по {final_id}"
+ elif start_id > 0: range_str = f"с {start_id}"
+ elif final_id > 0: range_str = f"до {final_id}"
+ msg_text += f"\nДиапазон ID: {range_str}"
+ await utils.answer(message, msg_text)
+
+ 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):
+ 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)
+ if not task:
+ logger.error(f"Задача {tid} не найдена в очереди для дампа.")
+ return
+ 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)
+ if map_t:
+ try:
+ dest = await self.client.get_entity(dest.id)
+ if not self._is_forum(dest):
+ logger.info("[%s] dest не форум, пытаемся включить топики...", tid)
+ ok = await self._ensure_forum_enabled(dest)
+ if ok:
+ await asyncio.sleep(2)
+ dest = await self.client.get_entity(dest.id)
+ if self._is_forum(dest):
+ logger.info("[%s] Режим топиков включён на dest в dumper", tid)
+ else:
+ logger.warning("[%s] _ensure_forum_enabled OK, но _is_forum False. Пробуем ещё раз...", tid)
+ await asyncio.sleep(3)
+ dest = await self.client.get_entity(dest.id)
+ if not self._is_forum(dest):
+ logger.warning("[%s] dest не является форумом после повторной проверки, пересылка без топиков", tid)
+ map_t = False
+ else:
+ logger.warning("[%s] dest не является форумом, пересылка без топиков", tid)
+ map_t = False
+ except Exception as e:
+ logger.warning("[%s] Ошибка обновления dest entity: %s", tid, e)
+ if map_t:
+ try:
+ src = await self.client.get_entity(src.id)
+ if not self._is_forum(src):
+ logger.warning("[%s] src не является форумом (хотя map_t=True), отключаем маппинг", tid)
+ map_t = False
+ except Exception as e:
+ logger.warning("[%s] Ошибка обновления src entity: %s", tid, e)
+ 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 max_id > 0: dumper_kwargs["max_id"] = max_id + 1
+ 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
+ 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 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,
+ 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
+ )
+ if tid not in self.active_dumps or self.active_dumps[tid].get("status") == "stopped": break
+ if tid in self.active_dumps:
+ self.active_dumps[tid]["current"] += processed
+ count = self.active_dumps[tid]["current"]
+ task['current'] = count
+ task['last_processed_id'] = batch[-1].id
+ self._save_tasks()
+ total = task.get('total_msgs', 0)
+ pct = round(count / total * 100, 1) if total else 0
+ spd = round(self.active_dumps[tid].get('current_speed', 0), 1)
+ logger.info("[%s] Прогресс: %d/%d (%.1f%%) | %.1f сооб/мин",
+ tid, count, total, pct, spd)
+ batch = []
+ if batch and self.active_dumps.get(tid, {}).get("status") != "stopped":
+ processed = await self._process_batch(
+ messages=list(batch), dest_id=dest.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
+ )
+ if tid in self.active_dumps:
+ 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":
+ task['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]
+ duration_seconds = time.time() - task_data.get('start_time', time.time())
+ duration_str = self._format_duration(duration_seconds)
+ active_seconds = duration_seconds - task_data.get('flood_total_seconds', 0)
+ if active_seconds <= 0: active_seconds = 1
+ avg_speed = round((count / active_seconds) * 60, 1)
+ chat_id_to_report = status_msg.chat_id if status_msg and status_msg.chat_id else task.get('status_chat_id')
+ done_string_key = "copy_done_detailed_prem" if self.is_premium else "copy_done_detailed_no_prem"
+ done_full = self.strings[done_string_key].format(
+ src=utils.escape_html(getattr(src, 'title', src.id)), dest=utils.escape_html(getattr(dest, 'title', dest.id)),
+ no_auth=kwargs.get("no_auth_str", "N/A"), no_capt=kwargs.get("no_capt_str", "N/A"),
+ start_id=kwargs.get("start_id_str", "N/A"), mode=kwargs.get("mode_str", "N/A"),
+ filter_type=filter_name, count=count, duration=duration_str,
+ avg_speed=avg_speed, flood_info=self._format_flood_stats(task_data)
+ )
+ # краткий итог в логи
+ logger.info(
+ "[✅ %s] Завершено: %d сообщений за %s | %.1f сооб/мин",
+ task_data.get('name', '?'), count, duration_str, avg_speed
+ )
+ # полный итог в чат где было запущено
+ if chat_id_to_report:
+ await self.client.send_message(chat_id_to_report, done_full)
+ except Exception as e:
+ logger.error(f"Dumper Error: {e}", exc_info=True)
+ 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'
+ 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):
+ if isinstance(message, types.MessageService):
+ return
+ if not getattr(message, 'chat_id', None):
+ return
+ raw_chat_id = str(message.chat_id)
+ normalized_id = self._get_normalized_id(getattr(message, 'chat', None))
+ chat_id_from_utils = "0"
+ if getattr(message, 'chat', None) and hasattr(utils, 'get_chat_id'):
+ try:
+ chat_id_from_utils = str(utils.get_chat_id(message.chat))
+ except Exception:
+ pass
+ possible_ids = [
+ normalized_id,
+ raw_chat_id,
+ raw_chat_id.replace("-100", ""),
+ f"-100{raw_chat_id.replace('-100', '').replace('-', '')}",
+ chat_id_from_utils
+ ]
+ cid = None
+ for test_id in possible_ids:
+ if test_id in self.watchlist:
+ cid = test_id
+ break
+ if not cid:
+ return
+ cfg = self.watchlist[cid]
+ filter_type = cfg.get("filter_type", FILTER_ALL)
+ last_id = self.last_processed_ids.get(cid, 0)
+ final_id = cfg.get("final_id", 0)
+ if message.id <= last_id:
+ return
+ if final_id > 0 and message.id > final_id:
+ return
+ if not self._should_include_message(message, filter_type):
+ self.last_processed_ids[cid] = message.id
+ 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)
+ 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 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")
+ }
+ if cid in self.watcher_flush_tasks:
+ self.watcher_flush_tasks[cid].cancel()
+ batch_size = self.config["batch_size"]
+ if not isinstance(batch_size, int):
+ batch_size = 100
+ if len(self.watcher_buffer[cid]) >= batch_size:
+ await self._flush_watcher_buffer(cid, cfg)
+ else:
+ self.watcher_flush_tasks[cid] = asyncio.get_event_loop().call_later(
+ 3.0,
+ lambda: asyncio.create_task(self._flush_watcher_buffer(cid, cfg))
+ )
+
+ async def _flush_watcher_buffer(self, cid, cfg): # опустошает буфер watcher'а: группирует альбомы и отправляет пачку в очередь на пересылку.
+ if cid not in self.watcher_buffer or not self.watcher_buffer[cid]:
+ return
+ msgs = self.watcher_buffer[cid].copy()
+ self.watcher_buffer[cid] = []
+ if cid in self.watcher_flush_tasks:
+ del self.watcher_flush_tasks[cid]
+ try:
+ cid_int = int(cid)
+ except (ValueError, TypeError):
+ logger.error(f"Watcher flush: неверный cid={cid}")
+ return
+ albums = {}
+ single_msgs = []
+ for msg in msgs:
+ if msg.grouped_id:
+ if msg.grouped_id not in albums:
+ albums[msg.grouped_id] = []
+ albums[msg.grouped_id].append(msg)
+ else:
+ single_msgs.append(msg)
+ for gid, album_msgs in albums.items():
+ sorted_album = sorted(album_msgs, key=lambda x: x.id)
+ try:
+ dest_entity = await self.client.get_entity(cfg["dest"])
+ src_entity = await self.client.get_entity(cid_int)
+ await self.queue.put({
+ "messages": sorted_album,
+ "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_entity,
+ "src_entity": src_entity,
+ "filter_type": cfg.get("filter_type", FILTER_ALL),
+ "watch_cid": cid
+ })
+ except Exception as e:
+ logger.error(f"Watcher album flush error (cid={cid}): {e}")
+ batch_size = self.config["batch_size"]
+ if not isinstance(batch_size, int):
+ batch_size = 100
+ for i in range(0, len(single_msgs), batch_size):
+ batch = single_msgs[i:i + batch_size]
+ try:
+ dest_entity = await self.client.get_entity(cfg["dest"])
+ src_entity = await self.client.get_entity(cid_int)
+ await self.queue.put({
+ "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_entity,
+ "src_entity": src_entity,
+ "filter_type": cfg.get("filter_type", FILTER_ALL),
+ "watch_cid": cid
+ })
+ except Exception as e:
+ logger.error(f"Watcher batch flush error (cid={cid}): {e}")
+
+ async def _catch_up_on_restart(self): # ватчер восстанавливает после перезагрузки
+ await asyncio.sleep(15)
+ for cid_str, cfg in self.watchlist.items():
+ try:
+ last_id = self.last_processed_ids.get(cid_str, 0)
+ if not isinstance(last_id, int):
+ last_id = 0
+ missed = []
+ batch_size = self.config["batch_size"]
+ if not isinstance(batch_size, int):
+ batch_size = 100
+ filter_type = cfg.get("filter_type", FILTER_ALL)
+ 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):
+ continue
+ if not isinstance(msg, types.MessageService) and self._should_include_message(msg, filter_type):
+ missed.append(msg)
+ if missed:
+ missed.sort(key=lambda x: x.id)
+ for i in range(0, len(missed), batch_size):
+ batch = missed[i:i + batch_size]
+ dest_ent = await self.client.get_entity(cfg["dest"])
+ src_ent = await self.client.get_entity(cid_int)
+ await self.queue.put({
+ "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
+ })
+ await asyncio.sleep(self.config["delay"])
+ except Exception as e:
+ logger.debug(f"Catch-up error for {cid_str}: {e}")
+
+ @loader.command()
+ async def cchelp(self, message: Message):
+ """— Подробная документация по модулю ChatCopy"""
+ help_text_prem = (
+ '🛡Подробная документация по модулю ChatCopy!\n\n'
+ '
1️⃣ Основные команды \n'
+ '🛫.chatcopy <откуда> <куда>[диапазон (от:до)] [флаги (можно несколько)]\n'
+ 'Копирует старую историю чата (делает дамп). Ставит задачу в очередь в случае если другая была запущена.\n'
+ '⚙️--now — Начать немедленно, без полного подсчёта (примерное кол-во сообщений запрашивается у Telegram мгновенно). Идеально для 110k+ медиа.\n\n'
+ '👀.ccwatch <откуда> <куда> [диапазон (от:до)] [флаги (можно несколько)]\n'
+ 'Режим слежки. Модуль будет висеть в фоне и моментально пересылать все новые сообщения. Функции [от:до] аналогичны .chatcopy\n\n'
+ '📺.ccpanel\n'
+ 'Открывает меню: управление задачами, пауза/стоп, статистика и настройки (скорость, задержка).\n\n'
+ '🗑.ccclear topics\n'
+ 'Очищает кэш топиков (полезно, если форум сломался и пересылает не в те разделы).
\n\n'
+ '
2️⃣ Источники и Диапазоны([от:до] функция) (ID)\n'
+ '✨Чаты: Можно использовать юзернеймы (@chat), ID (-100123...) или прямые ссылки на топики (t.me/c/123/45). Модуль сам всё распознает.\n'
+ '⚪️Диапазон [start:end]: Пишется слитно, без пробелов.\n'
+ '⚪️100:500 — скопировать с 100-го по 500-е сообщение.\n'
+ '⚪️100: — от 100-го до самых свежих.\n'
+ '⚪️:500 — с самого начала чата и до 500-го.
\n\n'
+ '
3️⃣ Флаги (Настройки текста)\n'
+ '🆕--now - начать пересылку сразу без подсчитывания, но без копирования топиков и последующей пересылки в них'
+ '👤-n — Скрыть автора (пересылка без плашки «Переслано от...»).\n'
+ '💬-dmc — Удалить подпись к медиа (оставит только голую картинку или файл, удалив текст под ним)(!Работает только с[-n] флагом!).
\n\n'
+ '
4️⃣ Фильтры контента\n'
+ '(Указывайте только один! Если не указать ничего — скопируется всё подряд)\n'
+ '📌--media — Любые медиа (фото, видео) и документы.\n'
+ '📷--photo_video — Строго только фото и видео (без гифок/стикеров).\n'
+ '💼--docs — Строго только документы (файлы, архивы, apk).\n'
+ '💬--text — Только чисто текстовые сообщения.
\n\n'
+ '
💡 Полные примеры использования\n'
+ '1. Полная копия канала со скрытием автора:\n'
+ '➡️.chatcopy @donor_channel @my_channel -n\n\n'
+ '2. Слежка за конкретным топиком (воруем только фото/видео без подписей):\n'
+ '➡️.ccwatch t.me/c/123/4t.me/c/321/5 -dmc --photo_video\n\n'
+ '3. Скопировать историю с 5000 по 6000 сообщение, только текст:\n'
+ '➡️.chatcopy -100111 -100222 5000:6000 --text
\n\n'
+ '💎 Приятного пользования!\n'
+ '❕ Единственный минус, не копирует с чатов с запрещенным копированием.'
+ )
+
+ help_text_no_prem = (
+ '🛡 Подробная документация по модулю ChatCopy!\n\n'
+ '
1️⃣ Основные команды \n'
+ '🛫 .chatcopy <откуда> <куда>[диапазон (от:до)] [флаги (можно несколько)]\n'
+ 'Копирует старую историю чата (делает дамп). Ставит задачу в очередь в случае если другая была запущена.\n'
+ '⚙️ --now — Начать немедленно, без полного подсчёта (примерное кол-во запрашивается у Telegram мгновенно). Идеально для 110k+ медиа.\n\n'
+ '👀 .ccwatch <откуда> <куда> [диапазон (от:до)] [флаги (можно несколько)]\n'
+ 'Режим слежки. Модуль будет висеть в фоне и моментально пересылать все новые сообщения. Функции [от:до] аналогичны .chatcopy\n\n'
+ '📺 .ccpanel\n'
+ 'Открывает меню: управление задачами, пауза/стоп, статистика и настройки (скорость, задержка).\n\n'
+ '🗑 .ccclear topics\n'
+ 'Очищает кэш топиков (полезно, если форум сломался и пересылает не в те разделы).
\n\n'
+ '
2️⃣ Источники и Диапазоны([от:до] функция) (ID)\n'
+ '✨ Чаты: Можно использовать юзернеймы (@chat), ID (-100123...) или прямые ссылки на топики (t.me/c/123/45). Модуль сам всё распознает.\n'
+ '⚪️ Диапазон [start:end]: Пишется слитно, без пробелов.\n'
+ '⚪️ 100:500 — скопировать с 100-го по 500-е сообщение.\n'
+ '⚪️ 100: — от 100-го до самых свежих.\n'
+ '⚪️ :500 — с самого начала чата и до 500-го.
\n\n'
+ '
3️⃣ Флаги (Настройки текста)\n'
+ '🆕 --now - начать пересылку сразу без подсчитывания, но без копирования топиков и последующей пересылки в них'
+ '👤 -n — Скрыть автора (пересылка без плашки «Переслано от...»).\n'
+ '💬 -dmc — Удалить подпись к медиа (оставит только голую картинку или файл, удалив текст под ним)(!Работает только с [-n] флагом!).
\n\n'
+ '
4️⃣ Фильтры контента\n'
+ '(Указывайте только один! Если не указать ничего — скопируется всё подряд)\n'
+ '📌 --media — Любые медиа (фото, видео) и документы.\n'
+ '📷 --photo_video — Строго только фото и видео (без гифок/стикеров).\n'
+ '💼 --docs — Строго только документы (файлы, архивы, apk).\n'
+ '💬 --text — Только чисто текстовые сообщения.
\n\n'
+ '
💡 Полные примеры использования\n'
+ '1. Полная копия канала со скрытием автора:\n'
+ '➡️ .chatcopy @donor_channel @my_channel -n\n\n'
+ '2. Слежка за конкретным топиком (воруем только фото/видео без подписей):\n'
+ '➡️ .ccwatch t.me/c/123/4t.me/c/321/5 -dmc --photo_video\n\n'
+ '3. Скопировать историю с 5000 по 6000 сообщение, только текст:\n'
+ '➡️ .chatcopy -100111 -100222 5000:6000 --text
\n\n'
+ '💎 Приятного пользования!\n'
+ '❕ Единственный минус, не копирует с чатов с запрещенным копированием.'
+ )
+ final_text = help_text_prem if self.is_premium else help_text_no_prem
+ await utils.answer(message, final_text)
+
+ @loader.command()
+ async def ccpanel(self, message: Message):
+ """Панель управления"""
+ await self._show_main_panel(message)
+
+ async def _show_main_panel(self, message, edit=False): # вот эта хрень это основная панель которая управляет кнопками и другим стафом
+ active_text = "Нет"
+ last_flood = "—"
+ if self.current_dump_task and self.current_dump_task in self.active_dumps:
+ task = self.active_dumps[self.current_dump_task]
+ name = utils.escape_html(task.get('name', 'Unknown'))
+ count = task.get('current', 0)
+ total = task.get('total_estimated', 0)
+ status = task.get('status', 'unknown')
+ start_ts = task.get('start_time', time.time())
+ elapsed = time.time() - start_ts
+ if status == 'running':
+ speed = task.get('current_speed', 0)
+ 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)
+ active_text = self.strings["panel_task_running"].format(
+ name=name,
+ count=count,
+ total=total,
+ speed=round(speed, 1),
+ progress=progress,
+ elapsed=elapsed_str,
+ eta=eta,
+ start_time=start_time,
+ end_time=end_time
+ )
+ elif status == 'paused':
+ 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 "неизвестно"
+ active_text = self.strings["panel_task_paused"].format(
+ name=name,
+ flood_time=fw_str,
+ count=count,
+ total=total,
+ speed=round(task.get('current_speed', 0), 1),
+ resume_time=resume_time
+ )
+ else:
+ active_text = f"{name}\n└ {status}"
+ elif self.last_flood_info.get("time"):
+ last_flood = self.last_flood_info["time"]
+ text = self.strings["panel_summary"].format(
+ queue_len=len([t for t in self.task_queue if t['status'] == 'queued']),
+ active=active_text,
+ watching_count=len(self.watchlist),
+ last_flood=last_flood
+ )
+ queue_size = self.queue.qsize()
+ if queue_size > 0:
+ text += f"\n📥 Очередь watcher: {queue_size}"
+ btns = [
+ [{"text": self.strings["btn_tasks"], "callback": self._panel_tasks}, {"text": self.strings["btn_watch"], "callback": self._panel_watching}],
+ [{"text": self.strings["btn_settings"], "callback": self._panel_settings}, {"text": self.strings["btn_stats"], "callback": self._panel_stats}]
+ ]
+ if edit:
+ await message.edit(text, reply_markup=btns)
+ else:
+ await self.inline.form(text=text, message=message, reply_markup=btns)
+
+ async def _panel_tasks(self, call): # описание ниже
+ """Панель очереди задач со списком"""
+ all_tasks = []
+ for i, task in enumerate(self.task_queue, 1):
+ task_with_num = task.copy()
+ task_with_num['display_num'] = i
+ all_tasks.append(task_with_num)
+ if not all_tasks:
+ text = self.strings["task_list_header"].format(total=0) + self.strings["no_tasks"]
+ btns = [[{"text": self.strings["btn_back"], "callback": self._cb_back}]]
+ await call.edit(text, reply_markup=btns)
+ return
+ text = self.strings["task_list_header"].format(total=len(all_tasks))
+ for task in all_tasks:
+ num = task['display_num']
+ src = utils.escape_html(task['src'][:20])
+ dest = utils.escape_html(task['dest'][:20])
+ status = task.get('status', 'queued')
+ if status == 'running':
+ active_data = self.active_dumps.get(task['tid'], {})
+ current = active_data.get('current', 0)
+ total = active_data.get('total_estimated', task.get('total_msgs', 0))
+ progress = round((current / total * 100), 1) if total > 0 else 0
+ text += self.strings["task_item_compact_running"].format(num=num, src=src, dest=dest, progress=progress) + "\n"
+ elif status == 'paused':
+ text += self.strings["task_item_compact_paused"].format(num=num, src=src, dest=dest) + "\n"
+ elif status == 'completed':
+ text += self.strings["task_item_compact_completed"].format(num=num, src=src, dest=dest) + "\n"
+ elif status == 'error':
+ text += self.strings["task_item_compact_error"].format(num=num, src=src, dest=dest) + "\n"
+ else:
+ wait_time = self._calculate_task_wait_time(task.get('position', num))
+ text += self.strings["task_item_compact_queued"].format(num=num, src=src, dest=dest, wait=wait_time) + "\n"
+ btns = []
+ row = []
+ for task in all_tasks:
+ num = task['display_num']
+ status = task.get('status', 'queued')
+ emoji = "⏳" if status == 'queued' else "▶️" if status == 'running' else "⚠️" if status == 'paused' else "✅" if status == 'completed' else "❌"
+ row.append({"text": f"{emoji}{num}", "callback": self._show_task_detail, "args": [task['tid'], num]})
+ if len(row) == 5:
+ btns.append(row)
+ row = []
+ if row:
+ btns.append(row)
+ btns.append([{"text": "🔄 Обновить", "callback": self._panel_tasks}])
+ btns.append([{"text": self.strings["btn_back"], "callback": self._cb_back}])
+ await call.edit(text, reply_markup=btns)
+
+ async def _show_task_detail(self, call, tid, num): # описание ниже
+ """Детальный просмотр задачи с точным расчётом времени"""
+ task = next((t for t in self.task_queue if t['tid'] == tid), None)
+ if not task:
+ history_task = next((t for t in self.task_history if t.get('tid') == tid), None)
+ if history_task:
+ await self._show_history_task_detail(call, history_task, num)
+ return
+ await call.answer("Задача не найдена")
+ return
+ status = task.get('status', 'queued')
+ src = utils.escape_html(task['src'])
+ dest = utils.escape_html(task['dest'])
+ total = task.get('total_msgs', 0)
+ position = task.get('position', num)
+ if status == 'running':
+ active_data = self.active_dumps.get(tid, {})
+ 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")
+ 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)
+ 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,
+ elapsed=elapsed_str, start_time=start_time, end_time=end_time, position=position
+ )
+ btns = [
+ [{"text": "⏸ Пауза", "callback": self._action_task, "args": [tid, "pause"]},
+ {"text": "🛑 Стоп", "callback": self._stop_specific, "args": [tid]}],
+ [{"text": "🔙 К списку", "callback": self._panel_tasks}]
+ ]
+ elif status == 'queued':
+ eta_start = self._calculate_task_wait_time(position)
+ estimated = self._estimate_duration(total)
+ text = self.strings["task_detail_queued"].format(
+ num=num, src=src, dest=dest, total=total, eta_start=eta_start,
+ position=position, estimated_duration=estimated
+ )
+ btns = [[{"text": "🗑 Удалить из очереди", "callback": self._remove_specific, "args": [tid]}],
+ [{"text": "🔙 К списку", "callback": self._panel_tasks}]
+ ]
+ elif status == 'paused':
+ active_data = self.active_dumps.get(tid, {})
+ current = active_data.get('current', 0)
+ flood_count = active_data.get('flood_count', 0)
+ 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 "неизвестно"
+ progress = round((current / total * 100), 1) if total > 0 else 0
+ remaining = max(0, total - current)
+ text = self.strings["task_detail_paused"].format(
+ num=num, src=src, dest=dest, current=current, total=total,
+ progress=progress, flood_count=flood_count,
+ flood_time=self._format_duration(flood_seconds),
+ resume_time=resume_time, speed=round(speed, 1), remaining=remaining
+ )
+ btns = [
+ [{"text": "▶️ Продолжить", "callback": self._action_task, "args": [tid, "resume"]},
+ {"text": "🛑 Стоп", "callback": self._stop_specific, "args": [tid]}],
+ [{"text": "🔙 К списку", "callback": self._panel_tasks}]
+ ]
+ elif status == 'completed':
+ await self._show_history_task_detail(call, task, num)
+ return
+ else:
+ text = self.strings["task_detail_error"].format(num=num, src=src, dest=dest)
+ btns = [
+ [{"text": "🗑 Удалить", "callback": self._remove_specific, "args": [tid]}],
+ [{"text": "🔙 К списку", "callback": self._panel_tasks}]
+ ]
+ await call.edit(text, reply_markup=btns)
+
+ async def _show_history_task_detail(self, call, task, num): # описание ниже
+ """Показывает детали завершённой задачи"""
+ 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))
+ if isinstance(end_time, datetime):
+ end_time_str = end_time.strftime("%H:%M:%S")
+ 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)
+ duration_seconds = time.time() - start_ts
+ else:
+ start_dt = start_ts
+ duration_seconds = (end_time - start_ts).total_seconds() if isinstance(end_time, datetime) else 0
+ duration_str = self._format_duration(duration_seconds)
+ floods = task.get('flood_count', 0)
+ avg_speed = round((count / duration_seconds) * 60, 1) if duration_seconds > 0 else 0
+ text = self.strings["task_detail_completed"].format(
+ num=num, src=src, dest=dest, count=count, duration=duration_str,
+ avg_speed=avg_speed, end_time=end_time_str, floods=floods
+ )
+ btns = [[{"text": "🔙 К списку", "callback": self._panel_tasks}]]
+ await call.edit(text, reply_markup=btns)
+
+ def _save_tasks(self):
+ """Saves the current task queue to DB, including live progress from active_dumps."""
+ tasks_to_save = []
+ for task in self.task_queue:
+ if task.get("status") in ["completed", "stopped", "error"]:
+ continue
+ snapshot = task.copy()
+ tid = snapshot.get('tid')
+ if tid and tid in self.active_dumps:
+ live = self.active_dumps[tid]
+ snapshot['current'] = live.get('current', snapshot.get('current', 0))
+ snapshot['total_msgs'] = live.get('total_estimated', snapshot.get('total_msgs', 0))
+ tasks_to_save.append(snapshot)
+ self.db.set("ChatCopy", "persistent_queue", tasks_to_save)
+
+ async def _action_task(self, call, tid, action): # вот эта хрень держит все что находится в панели, лучше не трогать
+ if tid in self.active_dumps:
+ if action == "pause":
+ self.active_dumps[tid]["status"] = "paused"
+ self.active_dumps[tid]["cancel"].clear()
+ for t in self.task_queue:
+ if t['tid'] == tid: t['status'] = 'paused'
+ elif action == "resume":
+ self.active_dumps[tid]["status"] = "running"
+ self.active_dumps[tid]["cancel"].set()
+ for t in self.task_queue:
+ if t['tid'] == tid: t['status'] = 'running'
+ elif action == "stop":
+ 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]
+ return await self._panel_tasks(call)
+ else:
+ if action == "stop":
+ self.task_queue = [t for t in self.task_queue if t['tid'] != tid]
+ return await self._panel_tasks(call)
+ await self._show_task_detail(call, tid, 0)
+
+ async def _stop_specific(self, call, tid): # останавливаем определенную задачу (копирование)
+ if tid in self.active_dumps:
+ 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() # сохраняем изменения
+ await call.answer("Задача остановлена")
+ await self._panel_tasks(call)
+
+ async def _remove_specific(self, call, tid): # удаляем определенную задачу (копирование)
+ if tid in self.active_dumps:
+ 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() # сохраняем изменения
+ await call.answer("Задача удалена из очереди")
+ await self._panel_tasks(call)
+
+ async def _panel_watching(self, call): # часть панели под кнопкой "Слежка", где ватчер следит за чатами
+ text = f"👀 Слежка ({len(self.watchlist)})\n\n"
+ btns = []
+ for i, (cid, cfg) in enumerate(self.watchlist.items(), 1):
+ info = self.last_watched.get(cid, {"name": cid, "time": "—"})
+ filter_name = self._get_filter_name(cfg.get("filter_type", FILTER_ALL))
+ text += f"{i}. {utils.escape_html(info['name'])}\n ID: {cid}\n Фильтр: {filter_name}\n Активность: {info['time']}\n\n"
+ btns.append({"text": f"🗑 {i}", "callback": self._stop_watch, "args": [cid]})
+ chunked_btns = utils.chunks(btns, 3) if btns else []
+ chunked_btns.append([{"text": self.strings["btn_back"], "callback": self._cb_back}])
+ await call.edit(text or "Пусто", reply_markup=chunked_btns)
+
+ async def _panel_settings(self, call): # ну тут очевидно, вместо кфг такие настроечки
+ text = (
+ f"⚙️ Настройки\n\n"
+ f"Batch size:{self.config['batch_size']}\n"
+ f"Delay:{self.config['delay']} сек\n"
+ f"FloodWait buffer:{self.config['flood_buffer']} сек"
+ )
+ btns = [
+ [{"text": "📦 +10", "callback": self._change_setting, "args": ["batch_size", 10]},
+ {"text": "📦 -10", "callback": self._change_setting, "args": ["batch_size", -10]}],
+ [{"text": "⏱ +5с", "callback": self._change_setting, "args": ["delay", 5]},
+ {"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": "🗑 Очистить кэш топиков", "callback": self._clear_topics_cache}],
+ [{"text": self.strings["btn_back"], "callback": self._cb_back}]
+ ]
+ await call.edit(text, reply_markup=btns)
+
+ async def _panel_stats(self, call): # в панеле статус вызываем и смотрим чо как идет копирование
+ total_tasks = len(self.task_stats)
+ completed = sum(1 for t in self.task_stats.values() if t.get('completed_at'))
+ stopped = total_tasks - completed
+ total_floods = sum(t.get('flood_count', 0) for t in self.task_stats.values())
+ total_flood_time = sum(t.get('flood_time', 0) for t in self.task_stats.values())
+ avg_speeds = [t.get('avg_speed', 0) for t in self.task_stats.values() if t.get('avg_speed', 0) > 0]
+ if self.current_dump_task and self.current_dump_task in self.active_dumps:
+ active_task_data = self.active_dumps[self.current_dump_task]
+ total_tasks += 1
+ total_floods += active_task_data.get('flood_count', 0)
+ total_flood_time += active_task_data.get('flood_total_seconds', 0)
+ if active_task_data.get('current_speed', 0) > 0:
+ avg_speeds.append(active_task_data['current_speed'])
+ global_avg = round(sum(avg_speeds) / len(avg_speeds), 1) if avg_speeds else 0
+ text = self.strings["stats_title"]
+ text += self.strings["stats_total"].format(
+ total=total_tasks,
+ completed=completed,
+ stopped=stopped,
+ floods=total_floods
+ )
+ if global_avg > 0:
+ text += f"\n⚡️ Средняя скорость: {global_avg} сообщений/мин"
+ if total_flood_time > 0:
+ hours = int(total_flood_time // 3600)
+ mins = int((total_flood_time % 3600) // 60)
+ text += f"\n⏱️ Общее время FW: {hours}ч {mins}м"
+ btns = [[{"text": self.strings["btn_back"], "callback": self._cb_back}]]
+ await call.edit(text, reply_markup=btns)
+
+ async def _change_setting(self, call, key, delta): # изменить настройки через панель чтоб в кфг не лезть
+ current = self.config[key]
+ if not isinstance(current, int):
+ current = 10 if key == "delay" else 100 if key == "batch_size" else 5
+ new_val = max(0, current + delta)
+ if key == "batch_size":
+ new_val = min(100, max(1, new_val))
+ elif key == "flood_buffer":
+ new_val = min(60, max(0, new_val))
+ else:
+ new_val = max(1, new_val)
+ self.config[key] = new_val
+ await self._panel_settings(call)
+
+ async def _clear_topics_cache(self, call): # ну, очевидно
+ self.topic_mapping = {}
+ self.topic_info_cache = {}
+ self.db.set("ChatCopy", "topic_mapping", {})
+ await call.answer("Кэш топиков очищен!")
+ await self._panel_settings(call)
+
+ async def _cb_back(self, call): # кнопка назад
+ await self._show_main_panel(call, edit=True)
+
+ async def _stop_watch(self, call, cid): # стопаем ватчер тута
+ if cid in self.watchlist:
+ if cid in self.watcher_buffer:
+ self.watcher_buffer[cid] = []
+ if cid in self.watcher_flush_tasks:
+ self.watcher_flush_tasks[cid].cancel()
+ del self.watcher_flush_tasks[cid]
+ del self.watchlist[cid]
+ self.db.set("ChatCopy", "watchlist", self.watchlist)
+ await call.answer("Удалено из слежки.")
+ await self._panel_watching(call)
+
+ @loader.command()
+ async def ccclear(self, message: Message):
+ """Очистить кэш маппинга топиков. Использование: .ccclear topics"""
+ args = utils.get_args_raw(message).strip().lower()
+ if args == "topics":
+ self.topic_mapping = {}
+ self.topic_info_cache = {}
+ self.db.set("ChatCopy", "topic_mapping", {})
+ await utils.answer(message, "🗑 Кэш топиков очищен")
+ else:
+ await utils.answer(message, "❌ Укажите что очистить: .ccclear topics")
diff --git a/SenkoGuardian/SenModules/Gemini.py b/SenkoGuardian/SenModules/Gemini.py
index 278f82f..ec4a4a4 100644
--- a/SenkoGuardian/SenModules/Gemini.py
+++ b/SenkoGuardian/SenModules/Gemini.py
@@ -1,9 +1,15 @@
# This file is part of SenkoGuardianModules
-# Copyright (c) 2025 Senko
+# Copyright (c) 2025-2026 Senko
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
-__version__ = (6, 1, 1) #  ̄へ ̄
+# scope heroku_min: 2.0.0
+# 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")
+
+""" ̄へ ̄"""
# meta developer: @SenkoGuardianModules
@@ -28,6 +34,8 @@ import tempfile
import aiohttp
from markdown_it import MarkdownIt
import pytz
+import httpx
+import pytz
# New SDK Check
try:
@@ -56,14 +64,40 @@ from ..inline.types import InlineCall
logger = logging.getLogger(__name__)
+_gemini_log_client = None
+_gemini_log_channel = None
+_gemini_log_topic_id = None
+
+class _GeminiTopicHandler(logging.Handler):
+ def emit(self, record):
+ if _gemini_log_client is None or _gemini_log_channel is None or _gemini_log_topic_id is None:
+ return
+ try:
+ text = f"[{record.levelname}] {self.format(record)}"
+ asyncio.ensure_future(
+ _gemini_log_client.send_message(
+ int(f"-100{_gemini_log_channel}"),
+ text,
+ parse_mode="html",
+ reply_to=_gemini_log_topic_id,
+ )
+ )
+ except Exception:
+ pass
+
+_gemini_topic_handler = _GeminiTopicHandler()
+_gemini_topic_handler.setLevel(logging.WARNING)
+logger.addHandler(_gemini_topic_handler)
+
DB_HISTORY_KEY = "gemini_conversations_v4"
DB_GAUTO_HISTORY_KEY = "gemini_gauto_conversations_v1"
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"
GEMINI_TIMEOUT = 840
MAX_FFMPEG_SIZE = 90 * 1024 * 1024
-DB_KEY_MAP_KEY = "gemini_key_model_map"
-CHECK_MODEL = "gemini-2.5-pro"
+CHECK_MODEL = "gemini-2.5-pro"
# requires: google-genai google-api-core pytz markdown_it_py
@@ -149,7 +183,7 @@ class Gemini(loader.Module):
"gme_chat_not_found": "🚫 Не удалось найти чат для экспорта:{}",
"gme_sent_to_saved": "💾 История экспортирована в избранное.",
"new_sdk_missing": "⚠️ Для работы модуля нужна библиотека google-genai.\nВыполните: pip install google-genai",
- "gprompt_usage": "ℹ️ Использование:\n.gprompt <текст> — установить промпт.\n.gprompt -c — очистить.\nИли ответьте на .txt файл.",
+ "gprompt_usage": "ℹ️ Использование:\n.gprompt <текст/пресет> — установить.\n.gprompt -c — очистить.\n.gpresets — база пресетов.",
"gprompt_updated": "✅ Системный промпт обновлен!\nДлина: {} символов.",
"gprompt_cleared": "🗑 Системный промпт очищен.",
"gprompt_current": "📝 Текущий системный промпт:",
@@ -159,7 +193,6 @@ class Gemini(loader.Module):
"gmodel_no_models": "⚠️ Не удалось получить список моделей.",
"gmodel_list_error": "❗️ Ошибка получения списка: {}",
"gimg_process": "✨Генерация...\n🧠 Модель: {model}",
- "gprompt_usage": "ℹ️ Использование:\n.gprompt <текст/пресет> — установить.\n.gprompt -c — очистить.\n.gpresets — база пресетов.",
"gpresets_usage": (
"ℹ️ Управление пресетами:\n"
"• .gpresets save [Имя] текст — сохранить (имя в скобках, если с пробелами).\n"
@@ -173,63 +206,62 @@ class Gemini(loader.Module):
"gpreset_not_found": "🚫 Пресет с таким именем или индексом не найден.",
"gpreset_list_head": "📋 Ваши пресеты:\n",
"gpreset_empty": "📂 Список пресетов пуст.",
-
}
TEXT_MIME_TYPES = {
"text/plain", "text/markdown", "text/html", "text/css", "text/csv",
"application/json", "application/xml", "application/x-python", "text/x-python",
"application/javascript", "application/x-sh",
}
+
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("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("timezone", "Europe/Moscow", self.strings["cfg_timezone_doc"]),
- loader.ConfigValue("proxy", "", self.strings["cfg_proxy_doc"]),
- loader.ConfigValue(
- "impersonation_prompt",
- (
- "Ты — это я, пользователь Telegram по имени {my_name}. Твоя задача — отвечать в чате от моего имени, основываясь на последних сообщениях. "
- "Веди себя естественно, как обычный человек, а не как ассистент. Не признавайся, что ты — ИИ, даже если тебя спросят напрямую. "
- "Твоя личность: [Здесь опишите свой характер: остроумный, серьезный, саркастичный, дружелюбный и т.д.].\n"
- "Правила:\n- Отвечай кратко и по делу.\n- Используй неформальный язык, сленг.\n- Не отвечай на каждое сообщение.\n- На медиа (стикер, фото) реагируй как человек ('лол', 'ору', 'жиза').\n- Не используй префиксы и кавычки.\n\n"
- "ИСТОРИЯ ЧАТА:\n{chat_history}\n\n{my_name}:"
- ),
- self.strings["cfg_impersonation_prompt_doc"], validator=loader.validators.String()
+ 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("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("timezone", "Europe/Moscow", self.strings["cfg_timezone_doc"]),
+ loader.ConfigValue("proxy", "", self.strings["cfg_proxy_doc"]),
+ loader.ConfigValue(
+ "impersonation_prompt",
+ (
+ "Ты — это я, пользователь Telegram по имени {my_name}. Твоя задача — отвечать в чате от моего имени, основываясь на последних сообщениях. "
+ "Веди себя естественно, как обычный человек, а не как ассистент. Не признавайся, что ты — ИИ, даже если тебя спросят напрямую. "
+ "Твоя личность:[Здесь опишите свой характер: остроумный, серьезный, саркастичный, дружелюбный и т.д.].\n"
+ "Правила:\n- Отвечай кратко и по делу.\n- Используй неформальный язык, сленг.\n- Не отвечай на каждое сообщение.\n- На медиа (стикер, фото) реагируй как человек ('лол', 'ору', 'жиза').\n- Не используй префиксы и кавычки.\n\n"
+ "ИСТОРИЯ ЧАТА:\n{chat_history}\n\n{my_name}:"
),
- loader.ConfigValue("impersonation_history_limit", 20, self.strings["cfg_impersonation_history_limit_doc"], validator=loader.validators.Integer(minimum=5, maximum=100)),
- loader.ConfigValue("impersonation_reply_chance", 0.25, self.strings["cfg_impersonation_reply_chance_doc"], validator=loader.validators.Float(minimum=0.0, maximum=1.0)),
- loader.ConfigValue("gauto_in_pm", False, "Разрешить авто-ответы в личных сообщениях (ЛС).", validator=loader.validators.Boolean()),
- loader.ConfigValue("google_search", False, self.strings["cfg_google_search_doc"], validator=loader.validators.Boolean()),
- loader.ConfigValue("temperature", 1.0, self.strings["cfg_temperature_doc"], validator=loader.validators.Float(minimum=0.0, maximum=2.0)),
- loader.ConfigValue("inline_pagination", False, self.strings["cfg_inline_pagination_doc"], validator=loader.validators.Boolean()),
- loader.ConfigValue("image_model_name", "gemini-2.5-flash-image", self.strings["cfg_image_model_doc"]),
- )
- self.prompt_presets = []
- self.conversations = {}
- self.gauto_conversations = {}
- self.last_requests = {}
- self.impersonation_chats = set()
- self._lock = asyncio.Lock()
- self.memory_disabled_chats = set()
- self.pager_cache = {}
- self.key_model_map = {}
- self.prompt_presets = []
- self.api_keys = []
+ self.strings["cfg_impersonation_prompt_doc"], validator=loader.validators.String()
+ ),
+ loader.ConfigValue("impersonation_history_limit", 20, self.strings["cfg_impersonation_history_limit_doc"], validator=loader.validators.Integer(minimum=5, maximum=100)),
+ loader.ConfigValue("impersonation_reply_chance", 0.25, self.strings["cfg_impersonation_reply_chance_doc"], validator=loader.validators.Float(minimum=0.0, maximum=1.0)),
+ loader.ConfigValue("gauto_in_pm", False, "Разрешить авто-ответы в личных сообщениях (ЛС).", validator=loader.validators.Boolean()),
+ loader.ConfigValue("google_search", False, self.strings["cfg_google_search_doc"], validator=loader.validators.Boolean()),
+ loader.ConfigValue("temperature", 1.0, self.strings["cfg_temperature_doc"], validator=loader.validators.Float(minimum=0.0, maximum=2.0)),
+ loader.ConfigValue("inline_pagination", False, self.strings["cfg_inline_pagination_doc"], validator=loader.validators.Boolean()),
+ loader.ConfigValue("image_model_name", "gemini-2.5-flash-image", self.strings["cfg_image_model_doc"]),
+ )
+ self.prompt_presets =[]
+ self.conversations = {}
+ self.gauto_conversations = {}
+ self.last_requests = {}
+ self.impersonation_chats = set()
+ self._lock = asyncio.Lock()
+ self.memory_disabled_chats = set()
+ self.pager_cache = {}
+ self.key_model_map = {}
+ self.api_keys =[]
async def client_ready(self, client, db):
self.client = client
self.db = db
self.me = await client.get_me()
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.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, {})
- keys_to_remove = [k for k in self.key_model_map if k not in self.api_keys]
+ 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]
self.db.set(self.strings["name"], DB_KEY_MAP_KEY, self.key_model_map)
@@ -240,16 +272,40 @@ class Gemini(loader.Module):
self.conversations = self._load_history_from_db(DB_HISTORY_KEY)
self.prompt_presets = self.db.get(self.strings["name"], DB_PRESETS_KEY, [])
if isinstance(self.prompt_presets, dict):
- self.prompt_presets = [{"name": k, "content": v} for k, v in self.prompt_presets.items()]
+ self.prompt_presets =[{"name": k, "content": v} for k, v in self.prompt_presets.items()]
self.gauto_conversations = self._load_history_from_db(DB_GAUTO_HISTORY_KEY)
- self.impersonation_chats = set(self.db.get(self.strings["name"], DB_IMPERSONATION_KEY, []))
+ self.impersonation_chats = set(self.db.get(self.strings["name"], DB_IMPERSONATION_KEY,[]))
+ self.pager_cache = self.db.get(self.strings["name"], DB_PAGER_CACHE_KEY, {})
if not self.api_keys:
logger.warning("Gemini: API ключи не настроены.")
+ global _gemini_log_client, _gemini_log_channel, _gemini_log_topic_id
+ try:
+ asset_channel = self._db.get("heroku.forums", "channel_id", 0)
+ if asset_channel:
+ notif_topic = await utils.asset_forum_topic(
+ self._client,
+ self._db,
+ asset_channel,
+ "Gemini Logs",
+ description="Gemini module warnings & errors.",
+ icon_emoji_id=5325547803936572038,
+ )
+ _gemini_log_client = self._client
+ _gemini_log_channel = asset_channel
+ _gemini_log_topic_id = notif_topic.id
+ except Exception:
+ pass
async def _prepare_parts(self, message: Message, custom_text: str=None):
final_parts, warnings = [], []
- prompt_text_chunks = []
+ prompt_text_chunks =[]
user_args = custom_text if custom_text is not None else utils.get_args_raw(message)
+ try:
+ chat = await message.get_chat()
+ chat_title = getattr(chat, 'title', getattr(chat, 'first_name', 'Личные сообщения'))
+ except Exception:
+ chat_title = "Неизвестный чат"
+ prompt_text_chunks.append(f"[System info: We are in '{chat_title}' chat]")
reply = await message.get_reply_message()
if reply and getattr(reply, "text", None):
try:
@@ -278,10 +334,12 @@ class Gemini(loader.Module):
mime_type = getattr(media_source.document, "mime_type", mime_type)
doc_attr = next((attr for attr in media_source.document.attributes if isinstance(attr, DocumentAttributeFilename)), None)
if doc_attr: filename = doc_attr.file_name
+
async def get_bytes(m):
bio = io.BytesIO()
await self.client.download_media(m, bio)
return bio.getvalue()
+
if mime_type.startswith("image/"):
try:
data = await get_bytes(media)
@@ -301,8 +359,10 @@ class Gemini(loader.Module):
if os.path.getsize(input_path) > MAX_FFMPEG_SIZE:
warnings.append(f"⚠️ Аудиофайл '{filename}' слишком большой."); raise StopIteration
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as temp_out: output_path = temp_out.name
- proc = await asyncio.create_subprocess_exec("ffmpeg", "-y", "-i", input_path, "-c:a", "libmp3lame", "-q:a", "2", output_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
- await proc.communicate()
+ ffmpeg_cmd =["ffmpeg", "-y", "-i", input_path, "-c:a", "libmp3lame", "-q:a", "2", output_path]
+ process_ffmpeg = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ await process_ffmpeg.communicate()
+ if process_ffmpeg.returncode != 0: raise Exception("FFmpeg error")
with open(output_path, "rb") as f:
final_parts.append(types.Part(inline_data=types.Blob(mime_type="audio/mpeg", data=f.read())))
except StopIteration: pass
@@ -317,20 +377,25 @@ class Gemini(loader.Module):
await self.client.download_media(media, input_path)
if os.path.getsize(input_path) > MAX_FFMPEG_SIZE:
warnings.append(f"⚠️ Медиафайл '{filename}' слишком большой."); raise StopIteration
- proc_probe = await asyncio.create_subprocess_exec("ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", input_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
- stdout, _ = await proc_probe.communicate()
+ ffprobe_cmd =["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", input_path]
+ process_probe = await asyncio.create_subprocess_exec(*ffprobe_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ stdout, _ = await process_probe.communicate()
has_audio = bool(stdout.strip())
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_out: output_path = temp_out.name
- cmd = ["ffmpeg", "-y", "-i", input_path]
+ ffmpeg_cmd =["ffmpeg", "-y", "-i", input_path]
maps = ["-map", "0:v:0"]
if not has_audio:
- cmd.extend(["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"])
+ ffmpeg_cmd.extend(["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"])
maps.extend(["-map", "1:a:0"])
else:
maps.extend(["-map", "0:a:0?"])
- cmd.extend([*maps, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-shortest", output_path])
- proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
- await proc.communicate()
+ ffmpeg_cmd.extend([*maps, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-shortest", output_path])
+ process_ffmpeg = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ _, stderr = await process_ffmpeg.communicate()
+ if process_ffmpeg.returncode != 0:
+ stderr_str = stderr.decode()
+ warnings.append(f"⚠️ Ошибка FFmpeg:\nНе удалось конвертировать '{filename}'. Детали:\n{utils.escape_html(stderr_str)}")
+ raise StopIteration
with open(output_path, "rb") as f:
final_parts.append(types.Part(inline_data=types.Blob(mime_type="video/mp4", data=f.read())))
except StopIteration: pass
@@ -338,6 +403,7 @@ class Gemini(loader.Module):
finally:
if input_path and os.path.exists(input_path): os.remove(input_path)
if output_path and os.path.exists(output_path): os.remove(output_path)
+
if not user_args and has_media and not final_parts and not any("[Содержимое файла" in chunk for chunk in prompt_text_chunks):
prompt_text_chunks.append(self.strings["media_reply_placeholder"])
full_prompt_text = "\n".join(chunk for chunk in prompt_text_chunks if chunk and chunk.strip()).strip()
@@ -353,6 +419,7 @@ class Gemini(loader.Module):
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:
current_turn_parts, request_text_for_display = self.last_requests.get(f"{chat_id}:{base_message_id}", (parts, "[регенерация]"))
@@ -361,18 +428,17 @@ class Gemini(loader.Module):
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 "[медиа-запрос]"
self.last_requests[f"{chat_id}:{base_message_id}"] = (current_turn_parts, request_text_for_display)
-
try:
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)
- history_key = "global_context" if (self.config.get("global_memory") and not impersonation_mode) else str(chat_id)
- raw_hist = self._get_structured_history(history_key, gauto=impersonation_mode)
+
+ raw_hist = self._get_structured_history(chat_id, 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 = []
+ content_list =[]
for p in current_turn_parts:
if hasattr(p, "text") and p.text:
content_list.append({"type": "text", "text": p.text})
@@ -388,26 +454,27 @@ class Gemini(loader.Module):
if not content_list:
content_list = request_text_for_display
openai_messages.append({"role": "user", "content": content_list})
- target_model = self.config["model_name"]
result_text = await self._send_to_Openrouter_api(target_model, openai_messages, self.config["temperature"])
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(history_key, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode)
+ self._update_history(chat_id, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode)
if impersonation_mode: return result_text
- hist_len = len(self._get_structured_history(history_key)) // 2
- mem_ind_fmt = self.strings.get("memory_status_global", self.strings["memory_status"])
- if self.config.get("global_memory"):
- mem_ind = mem_ind_fmt.format(hist_len)
+ hist_len = len(self._get_structured_history(chat_id)) // 2
+ max_hist = self.config["max_history_length"]
+ if max_hist <= 0:
+ mem_indicator = self.strings["memory_status_unlimited"].format(hist_len)
else:
- mem_ind = self.strings["memory_status"].format(hist_len, self.config["max_history_length"])
+ mem_indicator = self.strings["memory_status"].format(hist_len, max_hist)
model_info = f"OpenRouter: {target_model}"
response_html = self._markdown_to_html(result_text)
formatted_body = self._format_response_with_smart_separation(response_html)
question_html = f"
", "\n")
+ html_text=re.sub(r"(?i) ", "\n", html_text).strip()
return html_text
def _format_response_with_smart_separation(self, text: str) -> str:
@@ -1409,8 +1643,9 @@ class Gemini(loader.Module):
parts = re.split(pattern, text, flags=re.DOTALL)
result_parts = []
for i, part in enumerate(parts):
- if not part or part.isspace(): continue
- if i % 2 == 1:
+ if not part or part.isspace():
+ continue
+ if i % 2 == 1:
result_parts.append(part.strip())
else:
stripped_part = part.strip()
@@ -1418,11 +1653,10 @@ class Gemini(loader.Module):
result_parts.append(f'
{stripped_part}
')
return "\n".join(result_parts)
- def _get_inline_buttons(self, chat_id, base_message_id):
- return [[
- {"text": self.strings["btn_clear"], "callback": self._clear_callback, "args": (chat_id,)},
- {"text": self.strings["btn_regenerate"], "callback": self._regenerate_callback, "args": (base_message_id, chat_id)}
- ]]
+ def _get_inline_buttons(self, chat_id, base_message_id):
+ return [[{"text": self.strings["btn_clear"], "callback": self._clear_callback, "args": (chat_id,)},
+ {"text": self.strings["btn_regenerate"], "data": f"gemini:regen:{chat_id}:{base_message_id}"}]
+ ]
async def _safe_del_msg(self, msg, delay=1):
await asyncio.sleep(delay)
diff --git a/archquise/H.Modules/soundcloud.py b/archquise/H.Modules/soundcloud.py
new file mode 100644
index 0000000..d4d9390
--- /dev/null
+++ b/archquise/H.Modules/soundcloud.py
@@ -0,0 +1,875 @@
+# Proprietary License Agreement
+
+# Copyright (c) 2024-29 CodWiz
+
+# Permission is hereby granted to any person obtaining a copy of this software and associated documentation files (the "Software"), to use the Software for personal and non-commercial purposes, subject to the following conditions:
+
+# 1. The Software may not be modified, altered, or otherwise changed in any way without the explicit written permission of the author.
+
+# 2. Redistribution of the Software, in original or modified form, is strictly prohibited without the explicit written permission of the author.
+
+# 3. The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the author or copyright holder be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software.
+
+# 4. Any use of the Software must include the above copyright notice and this permission notice in all copies or substantial portions of the Software.
+
+# 5. By using the Software, you agree to be bound by the terms and conditions of this license.
+
+# For any inquiries or requests for permissions, please contact codwiz@yandex.ru.
+
+# ---------------------------------------------------------------------------------
+# Name: SoundCloud
+# Description: Card with the currently playing track on SoundCloud
+# Author: @hikka_mods
+# ---------------------------------------------------------------------------------
+# meta developer: @hikka_mods
+# scope: SoundCloud
+# scope: SoundCloud 0.0.2
+# requires: requests pillow yt-dlp
+# ---------------------------------------------------------------------------------
+
+import contextlib
+import dataclasses
+import functools
+import hashlib
+import io
+import logging
+from typing import Dict, List, Optional
+
+import requests
+from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
+from telethon.tl.types import Message
+from yt_dlp import YoutubeDL
+
+from .. import loader, utils
+
+logger = logging.getLogger(__name__)
+
+_API = "https://api-v2.soundcloud.com"
+_COVER_HQ = "-t500x500"
+
+_ORANGE = (255, 85, 0)
+_DIM = (155, 155, 170)
+_FADED = (100, 100, 115)
+_CARD_BG = (255, 255, 255, 14)
+_CARD_ACTIVE = (255, 255, 255, 26)
+_BAR_MUTED = (255, 255, 255, 16)
+
+
+@dataclasses.dataclass(frozen=True)
+class TrackInfo:
+ """Parsed SoundCloud track metadata."""
+
+ track_id: int
+ title: str
+ artist: str
+ duration_ms: int
+ permalink: str
+ cover_url: str
+ genre: str
+ plays: int
+ likes: int
+ reposts: int
+ comments: int
+
+ @classmethod
+ def parse(cls, raw: dict) -> "TrackInfo":
+ u = raw.get("user") or {}
+ return cls(
+ track_id=raw.get("id", 0),
+ title=raw.get("title") or "Unknown",
+ artist=u.get("username") or "Unknown",
+ duration_ms=raw.get("duration") or raw.get("full_duration") or 0,
+ permalink=raw.get("permalink_url") or "",
+ cover_url=raw.get("artwork_url") or u.get("avatar_url") or "",
+ genre=raw.get("genre") or "",
+ plays=raw.get("playback_count") or 0,
+ likes=raw.get("likes_count") or raw.get("favoritings_count") or 0,
+ reposts=raw.get("reposts_count") or 0,
+ comments=raw.get("comment_count") or 0,
+ )
+
+ @property
+ def duration_fmt(self) -> str:
+ s = self.duration_ms // 1000
+ return f"{s // 60}:{s % 60:02d}"
+
+ @property
+ def hq_cover(self) -> str:
+ return self.cover_url.replace("-large", _COVER_HQ)
+
+
+def _compact(n: int) -> str:
+ """Format large numbers: 12500 → 12.5K."""
+ if n >= 1_000_000:
+ return f"{n / 1_000_000:.1f}M"
+ if n >= 1_000:
+ return f"{n / 1_000:.1f}K"
+ return str(n)
+
+
+class _Fonts:
+ """Cached font loader from raw bytes."""
+
+ __slots__ = ("_raw", "_loaded")
+
+ def __init__(self, data: bytes):
+ self._raw = data
+ self._loaded: Dict[int, ImageFont.FreeTypeFont] = {}
+
+ def __call__(self, size: int) -> ImageFont.FreeTypeFont:
+ if size not in self._loaded:
+ self._loaded[size] = ImageFont.truetype(io.BytesIO(self._raw), size)
+ return self._loaded[size]
+
+ def fit(self, text: str, max_w: int, hi: int, lo: int) -> ImageFont.FreeTypeFont:
+ for s in range(hi, lo - 1, -2):
+ f = self(s)
+ if f.getlength(text) <= max_w:
+ return f
+ return self(lo)
+
+
+def _ellipsis(text: str, font: ImageFont.FreeTypeFont, max_w: int) -> str:
+ """Truncate text with '…' using binary search."""
+ if font.getlength(text) <= max_w:
+ return text
+ lo, hi = 0, len(text)
+ while lo < hi:
+ mid = (lo + hi + 1) // 2
+ if font.getlength(text[:mid] + "…") <= max_w:
+ lo = mid
+ else:
+ hi = mid - 1
+ return text[:lo] + "…"
+
+
+def _center_text(draw, text, font, y, canvas_w, fill="white"):
+ bb = draw.textbbox((0, 0), text, font=font)
+ draw.text(((canvas_w - bb[2] + bb[0]) // 2, y), text, font=font, fill=fill)
+
+
+def _frosted_bg(src: bytes, w: int, h: int, dim: float = 0.25) -> Image.Image:
+ """Blurred & dimmed background from cover art."""
+ img = Image.open(io.BytesIO(src)).convert("RGBA")
+ small = img.resize((max(w // 5, 1), max(h // 5, 1)), Image.Resampling.BILINEAR)
+ small = small.filter(ImageFilter.GaussianBlur(12))
+ result = small.resize((w, h), Image.Resampling.BILINEAR)
+ return ImageEnhance.Brightness(result).enhance(dim)
+
+
+def _gradient(
+ w: int, h: int, vertical: bool = True, c_from=(0, 0, 0, 160), c_to=(0, 0, 0, 40)
+) -> Image.Image:
+ """Fast linear gradient via 1px strip resize."""
+ length = h if vertical else w
+ strip = Image.new("RGBA", (1, length) if vertical else (length, 1))
+ px = strip.load()
+ for i in range(length):
+ t = i / max(length - 1, 1)
+ rgba = tuple(int(c_from[c] + (c_to[c] - c_from[c]) * t) for c in range(4))
+ if vertical:
+ px[0, i] = rgba
+ else:
+ px[i, 0] = rgba
+ return strip.resize((w, h), Image.Resampling.BILINEAR)
+
+
+def _round_corners(img: Image.Image, r: int) -> Image.Image:
+ mask = Image.new("L", img.size, 0)
+ ImageDraw.Draw(mask).rounded_rectangle((0, 0, *img.size), r, fill=255)
+ out = Image.new("RGBA", img.size, (0, 0, 0, 0))
+ out.paste(img, mask=mask)
+ return out
+
+
+def _rounded_cover(data: bytes, size: int, r: int) -> Image.Image:
+ img = Image.open(io.BytesIO(data)).convert("RGBA")
+ img = img.resize((size, size), Image.Resampling.LANCZOS)
+ return _round_corners(img, r)
+
+
+def _place_cover(
+ base: Image.Image,
+ cover_data: bytes,
+ size: int,
+ radius: int,
+ pos: tuple,
+ shadow_blur: int = 20,
+ shadow_alpha: int = 50,
+):
+ """Place cover with colored drop shadow (offset downward)."""
+ cover = _rounded_cover(cover_data, size, radius)
+ avg = cover.resize((1, 1), Image.Resampling.BILINEAR).getpixel((0, 0))
+
+ pad = shadow_blur * 2
+ offset_y = 8
+ canvas = Image.new(
+ "RGBA", (size + pad * 2, size + pad * 2 + offset_y), (0, 0, 0, 0)
+ )
+ shadow_shape = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ ImageDraw.Draw(shadow_shape).rounded_rectangle(
+ (0, 0, size, size), radius, fill=(*avg[:3], shadow_alpha)
+ )
+ canvas.paste(shadow_shape, (pad, pad + offset_y), shadow_shape)
+ canvas = canvas.filter(ImageFilter.GaussianBlur(shadow_blur))
+ canvas.paste(cover, (pad, pad), cover)
+
+ base.paste(canvas, (pos[0] - pad, pos[1] - pad), canvas)
+
+
+def _waveform(draw, x, y, w, h, bars=45, color=_ORANGE, muted=_BAR_MUTED, prog=0.0):
+ """Waveform visualization bars with sha256-seeded heights."""
+ bw = max(w // (bars * 2), 2)
+ gap = (w - bw * bars) // max(bars - 1, 1)
+ seed = hashlib.sha256(f"sc{bars}".encode()).digest()
+ for i in range(bars):
+ bx = x + i * (bw + gap)
+ amp = seed[i % len(seed)] / 255
+ bh = int(h * (0.25 + amp * 0.75))
+ by = y + (h - bh) // 2
+ c = color if i / bars <= prog else muted
+ draw.rounded_rectangle((bx, by, bx + bw, by + bh), bw // 2, fill=c)
+
+
+def _badge(
+ draw, text, font, x, y, fg="white", bg=(255, 255, 255, 18), px=12, py=5
+) -> int:
+ """Rounded pill badge. Returns width."""
+ bb = font.getbbox(text)
+ tw, th = bb[2] - bb[0], bb[3] - bb[1]
+ pw, ph = tw + px * 2, th + py * 2
+ draw.rounded_rectangle((x, y, x + pw, y + ph), ph // 2, fill=bg)
+ draw.text((x + px, y + py), text, font=font, fill=fg)
+ return pw
+
+
+def _export(img: Image.Image, name: str = "soundcloud.png") -> io.BytesIO:
+ buf = io.BytesIO()
+ img.save(buf, "PNG", optimize=True)
+ buf.seek(0)
+ buf.name = name
+ return buf
+
+
+class CardFactory:
+ """Generates visual cards for SoundCloud tracks."""
+
+ def __init__(self, fonts: _Fonts):
+ self._f = fonts
+
+ def square(self, track: TrackInfo, cover: bytes) -> io.BytesIO:
+ """Square now-playing card (800×800)."""
+ S = 800
+ p = 45
+
+ bg = _frosted_bg(cover, S, S, 0.22)
+ bg = Image.alpha_composite(
+ bg, _gradient(S, S, True, (0, 0, 0, 50), (0, 0, 0, 190))
+ )
+ draw = ImageDraw.Draw(bg)
+
+ bf = self._f(12)
+ draw.text((p, p), "SOUNDCLOUD", font=bf, fill=_ORANGE)
+ lw = bf.getlength("SOUNDCLOUD")
+ draw.line([(p, p + 17), (p + lw, p + 17)], fill=(*_ORANGE, 100), width=2)
+
+ cs = 310
+ cx, cy = (S - cs) // 2, p + 32
+ _place_cover(bg, cover, cs, 14, (cx, cy), shadow_blur=25, shadow_alpha=50)
+ draw = ImageDraw.Draw(bg)
+
+ wy = cy + cs + 30
+ _waveform(draw, p + 35, wy, S - p * 2 - 70, 26, bars=50)
+
+ tf = self._f(13)
+ draw.text((p + 35, wy + 30), "0:00", font=tf, fill=_FADED)
+ ds = track.duration_fmt
+ draw.text((S - p - 35 - tf.getlength(ds), wy + 30), ds, font=tf, fill=_FADED)
+
+ tw = S - p * 2
+ ty = wy + 56
+ title_f = self._f.fit(track.title, tw, 36, 20)
+ _center_text(draw, _ellipsis(track.title, title_f, tw), title_f, ty, S)
+
+ af = self._f.fit(track.artist, tw, 24, 16)
+ _center_text(draw, _ellipsis(track.artist, af, tw), af, ty + 44, S, _DIM)
+
+ sy = ty + 92
+ sf = self._f(14)
+ parts = []
+ if track.genre:
+ parts.append(track.genre)
+ if track.plays:
+ parts.append(f"▶ {_compact(track.plays)}")
+ if track.likes:
+ parts.append(f"♥ {_compact(track.likes)}")
+ if not parts:
+ parts.append(track.duration_fmt)
+ _center_text(draw, " · ".join(parts), sf, sy, S, _FADED)
+
+ return _export(_round_corners(bg, 22))
+
+ def horizontal(self, track: TrackInfo, cover: bytes) -> io.BytesIO:
+ """Wide now-playing card (1200×400)."""
+ W, H = 1200, 400
+ p = 40
+ cs = 280
+
+ bg = _frosted_bg(cover, W, H, 0.22)
+ bg = Image.alpha_composite(
+ bg, _gradient(W, H, False, (0, 0, 0, 180), (0, 0, 0, 60))
+ )
+
+ cvy = (H - cs) // 2
+ _place_cover(bg, cover, cs, 14, (p, cvy), shadow_blur=20, shadow_alpha=40)
+ draw = ImageDraw.Draw(bg)
+
+ bf = self._f(11)
+ draw.text((p, p - 6), "SOUNDCLOUD", font=bf, fill=_ORANGE)
+
+ if track.genre:
+ gf = self._f(12)
+ gt = track.genre.upper()
+ draw.text((W - p - gf.getlength(gt), p - 6), gt, font=gf, fill=_FADED)
+
+ tx = p + cs + 50
+ tw = W - tx - p
+
+ tty = cvy + 10
+ title_f = self._f.fit(track.title, tw, 36, 22)
+ draw.text(
+ (tx, tty),
+ _ellipsis(track.title, title_f, tw),
+ font=title_f,
+ fill="white",
+ )
+
+ af = self._f(22)
+ draw.text(
+ (tx, tty + 50),
+ _ellipsis(track.artist, af, tw),
+ font=af,
+ fill=_DIM,
+ )
+
+ by = tty + 98
+ bx = tx
+ pill_f = self._f(14)
+ bw = _badge(
+ draw,
+ track.duration_fmt,
+ pill_f,
+ bx,
+ by,
+ fg=_ORANGE,
+ bg=(*_ORANGE, 35),
+ )
+ bx += bw + 8
+ if track.plays:
+ bw = _badge(draw, f"▶ {_compact(track.plays)}", pill_f, bx, by, fg=_DIM)
+ bx += bw + 8
+ if track.likes:
+ _badge(draw, f"♥ {_compact(track.likes)}", pill_f, bx, by, fg=_DIM)
+
+ wy = cvy + cs - 50
+ _waveform(draw, tx, wy, tw, 22, bars=55)
+
+ wf = self._f(12)
+ draw.text((tx, wy + 26), "0:00", font=wf, fill=_FADED)
+ ds = track.duration_fmt
+ draw.text((tx + tw - wf.getlength(ds), wy + 26), ds, font=wf, fill=_FADED)
+
+ return _export(_round_corners(bg, 20))
+
+ def history(self, tracks: List[TrackInfo], fetch_cover) -> io.BytesIO:
+ """History card with dynamic height based on track count."""
+ W = 1200
+ p = 36
+ row_h = 120
+ gap = 8
+ hdr = 55
+ n = len(tracks)
+ H = p * 2 + hdr + n * row_h + (n - 1) * gap
+
+ bg_data = fetch_cover(tracks[0].hq_cover)
+ bg = _frosted_bg(bg_data, W, H, 0.18)
+ bg = Image.alpha_composite(bg, Image.new("RGBA", (W, H), (0, 0, 0, 150)))
+ draw = ImageDraw.Draw(bg)
+
+ hf = self._f(14)
+ draw.text((p, p), "SOUNDCLOUD", font=hf, fill=_ORANGE)
+ thf = self._f(22)
+ draw.text((p, p + 20), "Listening History", font=thf, fill="white")
+
+ lw = hf.getlength("SOUNDCLOUD")
+ draw.rounded_rectangle((p, p + 48, p + lw, p + 50), 1, fill=_ORANGE)
+
+ ct = f"{n} tracks"
+ draw.text((W - p - hf.getlength(ct), p + 22), ct, font=hf, fill=_FADED)
+
+ title_f = self._f(22)
+ artist_f = self._f(16)
+ time_f = self._f(14)
+ num_f = self._f(12)
+ cp = 12
+ cvsz = row_h - cp * 2
+ card_w = W - p * 2
+
+ yo = p + hdr + 8
+ for idx, trk in enumerate(tracks):
+ ry = int(yo)
+
+ card = Image.new("RGBA", (card_w, row_h), (0, 0, 0, 0))
+ cd = ImageDraw.Draw(card)
+ cd.rounded_rectangle(
+ (0, 0, card_w, row_h),
+ 12,
+ fill=_CARD_ACTIVE if idx == 0 else _CARD_BG,
+ )
+ if idx == 0:
+ cd.rounded_rectangle((0, 0, 4, row_h), 2, fill=_ORANGE)
+ region = bg.crop((p, ry, p + card_w, ry + row_h))
+ bg.paste(Image.alpha_composite(region, card), (p, ry))
+
+ try:
+ cv_data = fetch_cover(trk.hq_cover)
+ cv = _rounded_cover(cv_data, cvsz, 8)
+ bg.paste(cv, (p + cp + 6, ry + cp), cv)
+ except Exception:
+ pass
+
+ draw = ImageDraw.Draw(bg)
+
+ nt = f"{idx + 1:02d}"
+ nw = num_f.getlength(nt)
+ nx = p + cp + 6 + (cvsz - nw) // 2
+ ny = ry + cp + cvsz - 18
+ draw.rounded_rectangle(
+ (nx - 3, ny - 1, nx + nw + 3, ny + 14), 3, fill=(0, 0, 0, 170)
+ )
+ draw.text((nx, ny - 1), nt, font=num_f, fill=_ORANGE)
+
+ txt_x = p + cp + cvsz + 24
+ txt_w = card_w - cvsz - cp * 3 - 24 - 70
+ ty_center = ry + (row_h - 58) // 2
+
+ draw.text(
+ (txt_x, ty_center),
+ _ellipsis(trk.title, title_f, txt_w),
+ font=title_f,
+ fill="white",
+ )
+ draw.text(
+ (txt_x, ty_center + 30),
+ _ellipsis(trk.artist, artist_f, txt_w),
+ font=artist_f,
+ fill=_DIM,
+ )
+
+ dt = trk.duration_fmt
+ dw = time_f.getlength(dt)
+ draw.text(
+ (p + card_w - cp - dw - 8, ty_center + 4),
+ dt,
+ font=time_f,
+ fill=_FADED,
+ )
+
+ if trk.plays:
+ pt = f"▶ {_compact(trk.plays)}"
+ pw = time_f.getlength(pt)
+ draw.text(
+ (p + card_w - cp - pw - 8, ty_center + 24),
+ pt,
+ font=time_f,
+ fill=_FADED,
+ )
+
+ yo += row_h + gap
+
+ return _export(_round_corners(bg, 20), "soundcloud_history.png")
+
+
+def _require_token(func):
+ """Decorator: ensure oauth_token is configured."""
+
+ @functools.wraps(func)
+ async def wrapper(self, message, *a, **kw):
+ if not self.config["oauth_token"]:
+ return await utils.answer(message, self.strings("no_token"))
+ return await func(self, message, *a, **kw)
+
+ return wrapper
+
+
+def _catch_errors(func):
+ """Decorator: log & report exceptions to user."""
+
+ @functools.wraps(func)
+ async def wrapper(self, message, *a, **kw):
+ try:
+ return await func(self, message, *a, **kw)
+ except Exception:
+ logger.exception("SoundCloud: %s failed", func.__name__)
+ with contextlib.suppress(Exception):
+ import traceback
+
+ await utils.answer(
+ message, self.strings("error").format(traceback.format_exc())
+ )
+
+ return wrapper
+
+
+@loader.tds
+class SoundCloudMod(loader.Module):
+ """Display the currently playing SoundCloud track as a stylized card."""
+
+ strings = {
+ "name": "SoundCloud",
+ "no_token": (
+ "\u274c"
+ " Set oauth_token in module config\n\n"
+ "\U0001f511 Get it via extension:\n"
+ "\u2022 Chromium\n"
+ "\u2022 Firefox\n"
+ "\u2022 Or via DevTools: Application \u2192 Cookies \u2192 "
+ "oauth_token"
+ ),
+ "nothing": (
+ "❌"
+ " Nothing is playing right now"
+ ),
+ "error": (
+ "❌"
+ " Error\n{}"
+ ),
+ "wait_card": (
+ "\n\n🕔"
+ " Generating card…"
+ ),
+ "wait_dl": (
+ "\n\n🕔Downloading…"
+ ),
+ "dl_fail": (
+ "\n\n❌"
+ " Download failed"
+ ),
+ }
+
+ strings_ru = {
+ "no_token": (
+ "❌"
+ " Установи oauth_token"
+ " в конфиге модуля\n\n"
+ "🔑 Получить токен:\n"
+ "• Chromium\n"
+ "• Firefox\n"
+ "• Или через DevTools: Application → Cookies → "
+ "oauth_token"
+ ),
+ "nothing": (
+ "❌"
+ " Сейчас ничего не играет"
+ ),
+ "error": (
+ "❌"
+ " Ошибка\n{}"
+ ),
+ "wait_card": (
+ "\n\n🕔"
+ " Генерация карточки…"
+ ),
+ "wait_dl": (
+ "\n\n🕔Скачивание…"
+ ),
+ "dl_fail": (
+ "\n\n❌"
+ " Ошибка скачивания"
+ ),
+ }
+
+ def __init__(self):
+ self._font_data: Optional[bytes] = None
+ self._font_src: Optional[str] = None
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue(
+ "show_banner",
+ True,
+ "Generate image card",
+ validator=loader.validators.Boolean(),
+ ),
+ loader.ConfigValue(
+ "banner_type",
+ "square",
+ "Card layout",
+ validator=loader.validators.Choice(["square", "horizontal"]),
+ ),
+ loader.ConfigValue(
+ "template",
+ (
+ "🎧"
+ " Now playing: {artist} — {track}\n"
+ "🕓"
+ " {duration}{genre}\n"
+ "🔗"
+ " SoundCloud"
+ ),
+ "Message template. Placeholders: {track}, {artist},"
+ " {url}, {duration}, {genre}, {stats}",
+ validator=loader.validators.String(),
+ ),
+ loader.ConfigValue(
+ "font",
+ "https://github.com/web-fonts/ttf/raw/refs/heads/master/alk-sanet-webfont.ttf",
+ "URL to .ttf font file",
+ validator=loader.validators.String(),
+ ),
+ loader.ConfigValue(
+ "oauth_token",
+ "",
+ "SoundCloud OAuth token",
+ validator=loader.validators.String(),
+ ),
+ loader.ConfigValue(
+ "history_count",
+ 5,
+ "Tracks in history (3–5)",
+ validator=loader.validators.Integer(minimum=3, maximum=5),
+ ),
+ )
+
+ def _headers(self) -> dict:
+ return {
+ "Authorization": f"OAuth {self.config['oauth_token']}",
+ "Accept": "application/json",
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ ),
+ }
+
+ async def _get(self, path: str, **params) -> Optional[dict]:
+ try:
+ r = await utils.run_sync(
+ requests.get,
+ f"{_API}{path}",
+ headers=self._headers(),
+ params=params,
+ timeout=5,
+ )
+ if r.status_code == 200:
+ return r.json()
+ except Exception:
+ logger.debug("SC API %s failed", path)
+ return None
+
+ async def _load_font(self) -> bytes:
+ url = self.config["font"]
+ if self._font_data and self._font_src == url:
+ return self._font_data
+ data = await utils.run_sync(lambda: requests.get(url, timeout=10).content)
+ self._font_data = data
+ self._font_src = url
+ return data
+
+ async def _load_cover(self, url: str) -> Optional[bytes]:
+ try:
+ hq = url.replace("-large", _COVER_HQ)
+ r = await utils.run_sync(requests.get, hq, timeout=10)
+ if r.status_code == 200:
+ return r.content
+ except Exception:
+ pass
+ return None
+
+ async def _current(self) -> Optional[TrackInfo]:
+ for ep in ("/me/play-history/tracks", "/me/activities", "/stream"):
+ data = await self._get(ep, limit=3)
+ if not data:
+ continue
+ for item in data.get("collection", []):
+ raw = item.get("track") or item
+ if raw and "title" in raw and (raw.get("duration") or 0) > 0:
+ return TrackInfo.parse(raw)
+ return None
+
+ async def _recent(self, count: int) -> List[TrackInfo]:
+ data = await self._get("/me/play-history/tracks", limit=count)
+ if not data:
+ return []
+ return [
+ TrackInfo.parse(it["track"])
+ for it in data.get("collection", [])
+ if it.get("track") and "title" in it["track"]
+ ]
+
+ async def _download(self, url: str) -> Optional[bytes]:
+ try:
+ token = self.config["oauth_token"]
+ opts = {
+ "format": "best[ext=mp3]/best",
+ "quiet": True,
+ "no_warnings": True,
+ "http_headers": {
+ "Authorization": f"OAuth {token}",
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ ),
+ },
+ }
+
+ def _run():
+ with YoutubeDL(opts) as ydl:
+ info = ydl.extract_info(url, download=False)
+ audio = info.get("url")
+ if audio:
+ r = requests.get(audio, timeout=60)
+ if r.status_code == 200:
+ return r.content
+ return None
+
+ return await utils.run_sync(_run)
+ except Exception as e:
+ logger.error("Download failed: %s", e)
+ return None
+
+ def _format_message(self, t: TrackInfo) -> str:
+ genre_part = f" | {utils.escape_html(t.genre)}" if t.genre else ""
+ stats = []
+ if t.plays:
+ stats.append(f"▶ {_compact(t.plays)}")
+ if t.likes:
+ stats.append(f"♥ {_compact(t.likes)}")
+ return self.config["template"].format(
+ track=utils.escape_html(t.title),
+ artist=utils.escape_html(t.artist),
+ duration=t.duration_fmt,
+ url=t.permalink,
+ genre=genre_part,
+ stats=" · ".join(stats),
+ )
+
+ def _format_detail(self, t: TrackInfo) -> str:
+ parts = [t.duration_fmt]
+ if t.genre:
+ parts.append(utils.escape_html(t.genre))
+ if t.plays:
+ parts.append(f"▶ {_compact(t.plays)}")
+ if t.likes:
+ parts.append(f"♥ {_compact(t.likes)}")
+ info = " | ".join(parts)
+ return (
+ f"🎧"
+ f" {utils.escape_html(t.artist)} — {utils.escape_html(t.title)}\n"
+ f"🕓 {info}\n"
+ f"🔗"
+ f" SoundCloud"
+ )
+
+ @_catch_errors
+ @_require_token
+ @loader.command(
+ ru_doc="— Показать карточку текущего трека",
+ en_doc="— Show current track card",
+ )
+ async def scnow(self, message: Message):
+ track = await self._current()
+ if not track:
+ return await utils.answer(message, self.strings("nothing"))
+
+ text = self._format_message(track)
+
+ if not (self.config["show_banner"] and track.cover_url):
+ return await utils.answer(message, text)
+
+ msg = await utils.answer(message, text + self.strings("wait_card"))
+
+ cover = await self._load_cover(track.cover_url)
+ if not cover:
+ return await utils.answer(msg, text)
+
+ font_data = await self._load_font()
+ factory = CardFactory(_Fonts(font_data))
+
+ render = (
+ factory.square
+ if self.config["banner_type"] == "square"
+ else factory.horizontal
+ )
+ card = await utils.run_sync(render, track, cover)
+ await utils.answer(msg, text, file=card)
+
+ @_catch_errors
+ @_require_token
+ @loader.command(
+ ru_doc="— Скачать текущий трек",
+ en_doc="— Download current track",
+ )
+ async def scnowt(self, message: Message):
+ track = await self._current()
+ if not track:
+ return await utils.answer(message, self.strings("nothing"))
+
+ text = self._format_detail(track)
+ msg = await utils.answer(message, text + self.strings("wait_dl"))
+
+ audio = await self._download(track.permalink)
+ if not audio:
+ return await utils.answer(msg, text + self.strings("dl_fail"))
+
+ buf = io.BytesIO(audio)
+ buf.name = f"{track.artist} - {track.title}.mp3"
+ await utils.answer(msg, text, file=buf)
+
+ @_catch_errors
+ @_require_token
+ @loader.command(
+ ru_doc="— История прослушивания",
+ en_doc="— Listening history",
+ )
+ async def schistory(self, message: Message):
+ tracks = await self._recent(self.config["history_count"])
+ if not tracks:
+ return await utils.answer(message, self.strings("nothing"))
+
+ text = (
+ "📜"
+ " История прослушивания:\n\n"
+ )
+ for i, t in enumerate(tracks, 1):
+ parts = [t.duration_fmt]
+ if t.genre:
+ parts.append(utils.escape_html(t.genre))
+ if t.plays:
+ parts.append(f"▶ {_compact(t.plays)}")
+ meta = " | ".join(parts)
+ text += (
+ f"{i}. {utils.escape_html(t.artist)} —"
+ f" {utils.escape_html(t.title)}\n"
+ f" 🕓"
+ f" {meta} | Link\n\n"
+ )
+
+ if not self.config["show_banner"]:
+ return await utils.answer(message, text)
+
+ msg = await utils.answer(message, text + self.strings("wait_card"))
+ try:
+ font_data = await self._load_font()
+
+ def _render():
+ factory = CardFactory(_Fonts(font_data))
+
+ def fetcher(u):
+ return requests.get(u, timeout=10).content
+
+ return factory.history(tracks, fetcher)
+
+ card = await utils.run_sync(_render)
+ await utils.answer(msg, text, file=card)
+ except Exception:
+ await utils.answer(msg, text)
diff --git a/coddrago/modules/YaMusic.py b/coddrago/modules/YaMusic.py
index 2279cfe..036e671 100644
--- a/coddrago/modules/YaMusic.py
+++ b/coddrago/modules/YaMusic.py
@@ -1,11 +1,9 @@
-__version__ = (3, 1, 1)
+__version__ = (3, 2, 0)
# meta banner: https://raw.githubusercontent.com/kamekuro/hikka-mods/main/banners/yamusic.png
-# packurl: https://raw.githubusercontent.com/coddrago/assets/refs/heads/main/modules/yamusic.yml
-# meta banner: https://raw.githubusercontent.com/coddrago/modules/refs/heads/main/banner.png
+# packurl: https://raw.githubusercontent.com/coddrago/modules/refs/heads/dev/translations/yamusic.yml
# meta developer: @codrago_m
-# old meta dev: @kamekuro xuesos
# scope: heroku_only
-# scope: heroku_min 1.7.2
+# scope: heroku_min 2.0.0
# requires: aiohttp asyncio pillow>=10.0.0 git+https://github.com/MarshalX/yandex-music-api
import aiohttp
@@ -17,6 +15,7 @@ import random
import string
import typing
import time
+import uuid
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
import telethon
@@ -171,7 +170,6 @@ class Banners:
current_y += 80
bar_width = 800
- bar_height = 6
font_time = get_font(40)
bar_start_x = center_x - (bar_width // 2)
@@ -180,11 +178,12 @@ class Banners:
total_mins = self.duration // 1000 // 60
total_secs = (self.duration // 1000) % 60
- total_time_str = f"{total_mins}:{total_secs:02d}"
+
+ total_time_str = f"{total_mins:02d}:{total_secs:02d}"
cur_mins = self.progress // 1000 // 60
cur_secs = (self.progress // 1000) % 60
- cur_time_str = f"{cur_mins}:{cur_secs:02d}"
+ cur_time_str = f"{cur_mins:02d}:{cur_secs:02d}"
draw_text_shadow(
cur_time_str, (bar_start_x - 30, bar_y), font_time, anchor="rm"
@@ -193,34 +192,44 @@ class Banners:
total_time_str, (bar_end_x + 30, bar_y), font_time, anchor="lm"
)
- draw.line(
- [(bar_start_x, bar_y), (bar_end_x, bar_y)],
- fill=(255, 255, 255, 80),
- width=bar_height,
- )
-
+ old_state = random.getstate()
+
+ random.seed(self.title + str(self.duration))
+
+ num_bars = 65
+ bar_spacing = bar_width / num_bars
+ bar_w = max(4, int(bar_spacing * 0.5))
+ max_h = 50
+ min_h = 6
+
if self.duration > 0:
progress_ratio = self.progress / self.duration
else:
progress_ratio = 0
- progress_px = int(bar_width * progress_ratio)
- if progress_px > bar_width:
- progress_px = bar_width
+
+ active_bars = int(num_bars * progress_ratio)
- draw.line(
- [(bar_start_x, bar_y), (bar_start_x + progress_px, bar_y)],
- fill="white",
- width=bar_height + 5,
- )
- draw.ellipse(
- (
- bar_start_x + progress_px - 10,
- bar_y - 10,
- bar_start_x + progress_px + 10,
- bar_y + 10,
- ),
- fill="white",
- )
+ for i in range(num_bars):
+ base_h = random.randint(min_h, max_h)
+ edge_factor = 1.0 - abs((i - num_bars / 2) / (num_bars / 2))
+ h = int(base_h * 0.4 + max_h * edge_factor * 0.6)
+ h = max(min_h, h)
+
+ x_center = bar_start_x + i * bar_spacing
+ left = x_center - (bar_w / 2)
+ right = x_center + (bar_w / 2)
+ top = bar_y - (h / 2)
+ bottom = bar_y + (h / 2)
+
+ color = (255, 255, 255, 255) if i < active_bars else (80, 80, 80, 100)
+
+ draw.rounded_rectangle(
+ (left, top, right, bottom),
+ radius=int(bar_w / 2),
+ fill=color
+ )
+
+ random.setstate(old_state)
current_y += 80
@@ -312,13 +321,7 @@ class YaMusicMod(loader.Module):
"""The module for Yandex.Music streaming service"""
strings = {
- "name": "YaMusic",
- "iguide": '📜 Guide for obtaining access token for Yandex.Music',
- }
-
- strings_ru = {
- "_cls_doc": "Модуль для стримингового сервиса Яндекс.Музыка",
- "iguide": '📜 Гайд по получению токена Яндекс.Музыки',
+ "name": "YaMusic"
}
def __init__(self):
@@ -373,11 +376,10 @@ class YaMusicMod(loader.Module):
self._client: telethon.TelegramClient = client
self._db = db
- #utils.register_placeholder(
- #"now_play", self._now_play_placeholder, "placeholder for nowplay music"
- # Heroku 2.0.0 feature
- #)
- #utils.register_placeholder("duration", self._duration_placeholder, "progress bar")
+ utils.register_placeholder(
+ "now_play", self._now_play_placeholder, "placeholder for nowplay music"
+ )
+ utils.register_placeholder("duration", self._duration_placeholder, "progress bar")
if not self.get("guide_sent", False):
await self.inline.bot.send_message(self._tg_id, self.strings("iguide"))
@@ -423,7 +425,7 @@ class YaMusicMod(loader.Module):
me = await self._client.get_me()
self._premium = me.premium if hasattr(me, "premium") else False
- @loader.loop(15)
+ @loader.loop(30)
async def autobio(self):
if not self.config["token"]:
self.autobio.stop()
@@ -543,7 +545,7 @@ class YaMusicMod(loader.Module):
now = await self.__get_now_playing()
if not now or now.get("paused"):
return "Not Playing"
-
+
duration = now.get("duration_ms", 0)
progress = now.get("progress_ms", 0)
@@ -632,13 +634,14 @@ class YaMusicMod(loader.Module):
)
async def ynowcmd(self, message: telethon.types.Message):
"""👉 Get the banner of the track playing right now"""
+
+ await utils.answer(message, self.strings("uploading_banner"))
ym_client = await self._get_ym_client()
if not ym_client:
return await utils.answer(
message, self.strings("errors")["no_token_or_invalid"]
)
- await utils.answer(message, self.strings("uploading_banner"))
now = await self.__get_now_playing()
if not now or now.get("paused"):
@@ -694,10 +697,6 @@ class YaMusicMod(loader.Module):
.format(playlist_name),
link=f"Яндекс.Музыка",
)
- try:
- await utils.answer(message, out + self.strings("uploading_banner"))
- except Exception:
- pass
album_obj = track_object.albums[0] if track_object.albums else None
@@ -823,10 +822,6 @@ class YaMusicMod(loader.Module):
.format(playlist_name),
link=f"Яндекс.Музыка",
)
- try:
- await utils.answer(message, out + self.strings("downloading_track"))
- except Exception:
- pass
await utils.answer(
message=message,
@@ -954,6 +949,7 @@ class YaMusicMod(loader.Module):
),
)
+
async def __download_track(
self,
client: yandex_music.ClientAsync,
@@ -977,7 +973,7 @@ class YaMusicMod(loader.Module):
await asyncio.sleep(1)
continue
raise e
-
+
async def __get_ynison(self):
async def create_ws(token, ws_proto):
async with aiohttp.ClientSession() as session:
diff --git a/coddrago/modules/translations/yamusic.yml b/coddrago/modules/translations/yamusic.yml
new file mode 100644
index 0000000..12f694f
--- /dev/null
+++ b/coddrago/modules/translations/yamusic.yml
@@ -0,0 +1,116 @@
+en:
+ iguide: "📜Guide for obtaining access token for Yandex.Music"
+ search: "🎧{performer} — {title}\n🎵Yandex.Music | song.link"
+ downloading_track: "\n\n🕔Downloading audio…"
+ uploading_banner: "\n\n🕔Uploading banner…"
+ lyrics: "📜Lyrics of the {track} track:\n