__version__ = (1, 5, 0, 0)
# This file is a part of Hikka Userbot
# Code is NOT licensed under CC-BY-NC-ND 4.0 unless otherwise specified.
# 🌐 https://github.com/hikariatama/Hikka
# You CAN edit this file without direct permission from the author.
# You can redistribute this file with any modifications.
# meta developer: @yg_modules
# scope: hikka_only
# scope: hikka_min 1.6.3
# requires: google-generativeai pillow
# Changelog v1.5.0:
# - Чаты с контекстом: Новые команды (.gcreate, .gask, .gchats) для диалогов с Gemini, сохраняющих историю.
# - Регенерация ответов: Кнопка "🔄" под каждым ответом для получения другого варианта.
# - Расширенная поддержка медиа: Работает с фото, видео, аудио и голосовыми сообщениями.
# - Пагинация: Длинные ответы теперь разбиваются на страницы.
# - Гибкая конфигурация: Поддержка нескольких API-ключей, команда .gset для инструкции, команда .gmodels для просмотра моделей.
# - Улучшения интерфейса: Локализация (RU/UA), улучшенное форматирование, обработка цитат.
# █▄█ █░█ █▀▄▀█ █▀▄▀█ █▄█ █▀▄▀█ █▀█ █▀▄ █▀
# ░█░ █▄█ █░▀░█ █░▀░█ ░█░ █░▀░█ █▄█ █▄▀ ▄█
import os
import re
import asyncio
import logging
import random
import secrets
import tempfile
from typing import Any, Dict, List, Optional, Tuple, Union
import google.generativeai as genai
from google.generativeai.types import HarmBlockThreshold, HarmCategory
from telethon.extensions import html
from telethon.tl.types import Message
from telethon.utils import split_text
from .. import loader, utils
from ..inline.types import InlineCall
from ..inline.utils import Utils
logger = logging.getLogger(__name__)
@loader.tds
class yg_gemini(loader.Module):
"""Gemini AI module with media support, regeneration, and chat context!"""
strings = {
"name": "yg_gemini",
"no_keys": "{self.get_prefix()}cfg yg_gemini)",
"api_error": "Gemini API Error: {error}",
"unknown_error": "An unknown error occurred: {error}",
"list_models_error": "{mime_type} is not supported by the module!",
"media_upload_error": "{prefix}gask <chat_id> <question>",
"models_title": "✨ Available Gemini Models:\n\n",
"model_entry": "🔹 {name}\n ▫️ {desc}\n",
"models_fetch": "{chat_id}\nName: {name}",
"chat_not_found": "{chat_id} not found",
"chat_no_chats": "😕 You don't have any Gemini chats yet. Use {prefix}gcreate!",
"chat_list_title": "💾 Your Gemini Chats:",
"chat_menu_title": "🔧 Chat {chat_id} ({name}):",
"chat_menu_history": "📜 History",
"chat_menu_delete": "🗑 Delete",
"chat_menu_back": "🔙 Back",
"chat_hist_title": "📜 Chat History {chat_id} ({name}) (Page {current_page}/{total_pages}):",
"chat_hist_entry": "{role}:
{text}", "chat_hist_empty": "Chat history is empty!", "chat_del_confirm": "❓ Delete chat {chat_id} ({name})? This is irreversible.", "chat_del_yes": "✅ Yes", "chat_del_no": "❌ No", "chat_del_ok": "✅ Chat {chat_id} deleted!", "gask_loading": "
{chat_id}: {question}\n\n{chat_id}: {question}\n\n{chat_id}: {question}\n\n{self.get_prefix()}cfg yg_gemini)",
"api_error": "Ошибка API Gemini: {error}",
"unknown_error": "Произошла неизвестная ошибка: {error}",
"list_models_error": "{mime_type} не поддерживается модулем!",
"media_upload_error": "{prefix}gask <id_чата> <вопрос>",
"models_title": "✨ Доступные модели Gemini:\n\n",
"model_entry": "🔹 {name}\n ▫️ {desc}\n",
"models_fetch": "{chat_id}\nИмя: {name}",
"chat_not_found": "{chat_id} не найден",
"chat_no_chats": "😕 У вас пока нет чатов Gemini. Используйте {prefix}gcreate!",
"chat_list_title": "💾 Ваши чаты Gemini:",
"chat_menu_title": "🔧 Чат {chat_id} ({name}):",
"chat_menu_history": "📜 История",
"chat_menu_delete": "🗑 Удалить",
"chat_menu_back": "🔙 Назад",
"chat_hist_title": "📜 История чата {chat_id} ({name}) (Стр. {current_page}/{total_pages}):",
"chat_hist_entry": "{role}: {text}", "chat_hist_empty": "История чата пуста!", "chat_del_confirm": "❓ Удалить чат {chat_id} ({name})? Это необратимо.", "chat_del_yes": "✅ Да", "chat_del_no": "❌ Нет", "chat_del_ok": "✅ Чат {chat_id} удален!", "gask_loading": "
{chat_id}: {question}\n\n{chat_id}: {question}\n\n{chat_id}: {question}\n\n{self.get_prefix()}cfg yg_gemini)",
"api_error": "Помилка API Gemini: {error}",
"unknown_error": "Сталася невідома помилка: {error}",
"list_models_error": "{mime_type} не підтримується модулем!",
"media_upload_error": "{prefix}gask <id_чату> <питання>",
"models_title": "✨ Доступні моделі Gemini:\n\n",
"model_entry": "🔹 {name}\n ▫️ {desc}\n",
"models_fetch": "{chat_id}\nІм'я: {name}",
"chat_not_found": "{chat_id} не знайдено",
"chat_no_chats": "😕 У вас ще немає чатів Gemini. Використовуйте {prefix}gcreate!",
"chat_list_title": "💾 Ваші чати Gemini:",
"chat_menu_title": "🔧 Чат {chat_id} ({name}):",
"chat_menu_history": "📜 Історія",
"chat_menu_delete": "🗑 Видалити",
"chat_menu_back": "🔙 Назад",
"chat_hist_title": "📜 Історія чату {chat_id} ({name}) (Стор. {current_page}/{total_pages}):",
"chat_hist_entry": "{role}: {text}", "chat_hist_empty": "Історія чату порожня!", "chat_del_confirm": "❓ Видалити чат {chat_id} ({name})? Це незворотно.", "chat_del_yes": "✅ Так", "chat_del_no": "❌ Ні", "chat_del_ok": "✅ Чат {chat_id} видалено!", "gask_loading": "
{chat_id}: {question}\n\n{chat_id}: {question}\n\n{chat_id}: {question}\n\n{utils.escape_html(m['name'])}",
desc=utils.escape_html(m['desc'])
) for m in found
)
await utils.answer(status, out)
except Exception as e:
err = self.strings("list_models_error").format(error=utils.escape_html(str(e)))
await utils.answer(status, err)
async def _call_gemini_api(
self,
api_key: str,
content: Optional[Union[str, List[Union[str, genai.types.File]]]] = None,
chat_mode: bool = False,
history: Optional[List[Dict[str, Any]]] = None
) -> str:
if not api_key:
raise ValueError("API Key is required!")
try:
genai.configure(api_key=api_key)
system_instruction = self.config["system_instruction"] if self.config["system_instruction"] else None
model = genai.GenerativeModel(model_name=self.config["model_name"],
system_instruction=system_instruction,
safety_settings=self.safety_settings)
if chat_mode:
if history is None: raise ValueError("Chat mode requires history")
chat = model.start_chat(history=history)
last_prompt_content = history[-1]['parts']
response = await chat.send_message_async(
last_prompt_content,
safety_settings=self.safety_settings)
else:
if content is None: raise ValueError("Stateless mode requires content")
if isinstance(content, str): content = [content]
response = await model.generate_content_async(content,
safety_settings=self.safety_settings)
if not response.parts: return f"⚠️ The answer is blocked by Gemini or empty"
return response.text.strip()
except Exception as e:
err_info = getattr(e, 'message', str(e))
raise RuntimeError(f"{err_info}") from e
async def _regenerate_stateless_callback(
self,
call: InlineCall,
prompt_text: str,
file_name: Optional[str],
media_type: Optional[str],
user_prompt: str,
key: str,
):
if not key:
await call.answer(self.strings("unknown_error").format(error="Missing API key"), show_alert=True)
return
escaped = utils.escape_html(user_prompt)
loading = ""
args = {}
if user_prompt:
loading = "regenerating_media_query" if media_type else "regenerating"
args = {"prompt": escaped, "media_type": media_type}
elif media_type:
loading = "regenerating_media_only"
args = {"media_type": media_type}
text = self.strings(loading).format(**args) if loading else "🔄 Regeneration..."
await call.edit(text=text)
try:
content = []
if file_name:
try:
genai.configure(api_key=key)
file = await utils.run_sync(genai.get_file, name=file_name)
content.append(file)
except Exception as e:
await call.answer(f"⚠️ Media not found ({file_name}). Text only.", show_alert=False)
media_type = file_name = None
if prompt_text:
content.append(prompt_text)
if not content:
await call.edit(text=self.strings("unknown_error").format(error="No content"), reply_markup=None)
return
raw = await self._call_gemini_api(key, content=content, chat_mode=False)
html = self.markdown2html(raw)
response = self.make_expandable(html)
base = ""
if user_prompt:
base = self.strings("resp_base_media").format(media_type=media_type, prompt=escaped) if media_type else self.strings("resp_base").format(prompt=escaped)
elif media_type:
base = self.strings("resp_base_media_only").format(media_type=media_type)
final = f"{base}\n{response}"
args = (prompt_text, file_name, media_type, user_prompt, key)
if len(final.encode()) > 3996:
pages = await utils.run_sync(self._get_pages, response)
if pages:
await self._paginate_response_callback(
call=call,
message=None,
pages=pages,
current_page_index=0,
constructed_base_text=base,
regenerate_callback=self._regenerate_stateless_callback,
regenerate_args=args,
)
else:
btns = [[{"text": self.strings("btn_regenerate"), "callback": self._regenerate_stateless_callback, "args": args}]]
await call.edit(text=f"{base}\n{self.strings('pagination_error')}", reply_markup=btns)
else:
btns = [[{"text": self.strings("btn_regenerate"), "callback": self._regenerate_stateless_callback, "args": args}]]
await call.edit(text=final, reply_markup=btns)
except (RuntimeError, ValueError) as e:
await call.edit(text=self.strings("api_error").format(error=utils.escape_html(str(e))), reply_markup=None)
except Exception as e:
await call.edit(text=self.strings("unknown_error").format(error=utils.escape_html(str(e))), reply_markup=None)
def _get_chats(self) -> Dict[str, Dict[str, Any]]:
chats = self.db.get("yg_gemini", "chats", {})
return chats if isinstance(chats, dict) else {}
def _save_chats(self, chats: Dict[str, Dict[str, Any]]):
self.db.set("yg_gemini", "chats", chats)
async def _process_chat_request(self, msg: Message, chat_id: str, question: str):
chats = self._get_chats()
if chat_id not in chats:
await utils.answer(msg, self.strings("chat_not_found").format(chat_id=chat_id))
return
chat = chats[chat_id]
hist = chat.get("history")
if not isinstance(hist, list):
logger.warning(f"Invalid history for {chat_id}, resetting.")
hist = []
chat["history"] = hist
quote = self._format_quote(msg)
final_q = f"{quote}\n\n{question}" if quote else question
hist.append({"role": "user", "parts": [final_q]})
status = await utils.answer(
msg,
self.strings("gask_loading").format(
chat_id=chat_id,
question=utils.escape_html(question)
)
)
if not isinstance(status, Message):
if hist and hist[-1]["role"] == "user":
hist.pop()
await self.client.send_message(msg.chat_id, self.strings("unknown_error").format(error="No status message"))
return
key = self._get_api_key()
if not key:
if hist and hist[-1]["role"] == "user":
hist.pop()
await utils.answer(status, self.strings("no_keys").format(prefix=self.get_prefix()))
return
await self._generate_and_display_chat(
msg=status,
key=key,
chat_id=chat_id,
last_question=question
)
async def _generate_and_display_chat(self, msg: Message, key: str, chat_id: str, last_question: str):
chats = self._get_chats()
if chat_id not in chats or not isinstance(chats[chat_id].get("history"), list):
await utils.answer(msg, self.strings("chat_not_found").format(chat_id=chat_id))
return
hist = chats[chat_id]["history"]
try:
raw = await self._call_gemini_api(key, history=hist, chat_mode=True)
hist.append({"role": "model", "parts": [raw]})
chats[chat_id]["history"] = hist
self._save_chats(chats)
html = self.markdown2html(raw)
final = self.make_expandable(html)
base = self.strings("gask_resp_base").format(
chat_id=chat_id, question=utils.escape_html(last_question))
text = f"{base}\n{final}"
regen_args = (chat_id, last_question)
if len(text.encode("utf-8")) > 3996:
pages = await utils.run_sync(self._get_pages, final)
if pages:
await self._paginate_response_callback(
call=None, message=msg, pages=pages, current_page_index=0,
constructed_base_text=base,
regenerate_callback=self._regenerate_chat_callback,
regenerate_args=regen_args,
)
else:
buttons = [[{"text": self.strings("btn_regenerate"),
"callback": self._regenerate_chat_callback,
"args": regen_args}]]
await self.inline.form(
message=msg, text=f"{base}\n{self.strings('pagination_error')}",
reply_markup=buttons, silent=True,
)
else:
buttons = [[{"text": self.strings("btn_regenerate"),
"callback": self._regenerate_chat_callback,
"args": regen_args}]]
await self.inline.form(message=msg, text=text, reply_markup=buttons, silent=True)
except (RuntimeError, ValueError) as e:
if hist and hist[-1].get("role") == "user":
hist.pop()
chats[chat_id]["history"] = hist
self._save_chats(chats)
logger.info(f"Removed last user msg from {chat_id} after API error")
err = self.strings("api_error").format(error=utils.escape_html(str(e)))
await utils.answer(msg, err)
except Exception as e:
if hist and hist[-1].get("role") == "user":
hist.pop()
chats[chat_id]["history"] = hist
self._save_chats(chats)
logger.info(f"Removed last user msg from {chat_id} after unexpected error")
err = self.strings("unknown_error").format(error=utils.escape_html(str(e)))
await utils.answer(msg, err)
async def _regenerate_chat_callback(self, call: InlineCall, chat_id: str, last_question_display: str):
api_key = self._get_api_key()
if not api_key:
await call.answer(self.strings("no_keys").format(prefix=self.get_prefix()), show_alert=True)
return
chats = self._get_chats()
if chat_id not in chats or not isinstance(chats[chat_id].get("history"), list):
await call.answer(self.strings("chat_not_found").format(chat_id=chat_id), show_alert=True)
return
history = chats[chat_id]["history"]
regen_text = self.strings("gask_regenerating").format(chat_id=chat_id, question=utils.escape_html(last_question_display))
if not await call.edit(text=regen_text, reply_markup=None):
return
try:
if history and history[-1].get("role") == "model":
history.pop()
elif not history or history[-1].get("role") != "user":
await call.edit(text=self.strings("unknown_error").format(error="Corrupted history"), reply_markup=None)
return
new_resp = await self._call_gemini_api(api_key, history=history, chat_mode=True)
history.append({'role': 'model', 'parts': [new_resp]})
chats[chat_id]["history"] = history
self._save_chats(chats)
html = self.markdown2html(new_resp)
part = self.make_expandable(html)
q = utils.escape_html(last_question_display)
base = self.strings("gask_resp_base").format(chat_id=chat_id, question=q)
text = f"{base}\n{part}"
args = (chat_id, last_question_display)
if len(text.encode()) > 3996:
pages = await utils.run_sync(self._get_pages, part)
if pages:
await self._paginate_response_callback(
call=call, message=None, pages=pages, current_page_index=0,
constructed_base_text=base,
regenerate_callback=self._regenerate_chat_callback,
regenerate_args=args,
)
else:
btns = [[{"text": self.strings("btn_regenerate"), "callback": self._regenerate_chat_callback, "args": args}]]
await call.edit(text=f"{base}\n{self.strings('pagination_error')}", reply_markup=btns)
else:
btns = [[{"text": self.strings("btn_regenerate"), "callback": self._regenerate_chat_callback, "args": args}]]
await call.edit(text=text, reply_markup=btns)
except (RuntimeError, ValueError) as e:
err = self.strings("api_error").format(error=utils.escape_html(str(e)))
await call.edit(text=err, reply_markup=None)
except Exception as e:
err = self.strings("unknown_error").format(error=utils.escape_html(str(e)))
await call.edit(text=err, reply_markup=None)
async def _process_stateless_request(self, msg: Message):
args = utils.get_args_raw(msg)
prompt = args.strip() if args else ""
quote = self._format_quote(msg)
full_prompt = f"{quote}\n\n{prompt}" if prompt else quote
reply = await msg.get_reply_message()
parts, uploaded, mime, mtype, api_key = [], None, None, None, None
status = msg
loading_key = None
if reply and reply.media:
try:
media_info = self._get_media_info(reply)
if media_info:
dl_func, mime, mtype = media_info
uploaded, api_key = await self._upload_media(status, dl_func, mime, mtype)
if uploaded:
parts.append(uploaded)
else:
return
else:
mime_display = (getattr(reply.media.document, 'mime_type', "N/A")
if hasattr(reply.media, 'document') else
"image/jpeg" if reply.photo else "N/A")
await utils.answer(status, self.strings("media_unsupported").format(
mime_type=utils.escape_html(mime_display)))
return
except Exception as e:
await utils.answer(status, self.strings("media_dl_error").format(
media_type=mtype or "media",
error=utils.escape_html(str(e))
))
return
if full_prompt:
parts.append(full_prompt)
elif not parts:
await utils.answer(msg, self.strings("gemini_no_prompt"))
return
api_key = api_key or self._get_api_key()
if not api_key:
await utils.answer(status, self.strings("no_keys").format(prefix=self.get_prefix()))
return
escaped = utils.escape_html(prompt)
if prompt:
loading_key = "loading_media_query" if mtype else "loading"
elif mtype:
loading_key = "loading_media_only"
if loading_key:
args = {"prompt": escaped, "media_type": mtype} if prompt else {"media_type": mtype}
await utils.answer(status, self.strings(loading_key).format(**args))
await self._generate_and_display_stateless(
status_msg=status,
api_key=api_key,
content_parts=parts,
user_prompt_display=prompt,
uploaded_file=uploaded,
media_type_str=mtype
)
async def _generate_and_display_stateless(
self,
status_msg: Message,
api_key: str,
content_parts: list,
user_prompt_display: str,
uploaded_file: Optional[genai.types.File],
media_type_str: Optional[str],
):
try:
raw = await self._call_gemini_api(api_key, content=content_parts, chat_mode=False)
html = self.markdown2html(raw)
response = self.make_expandable(html)
escaped = utils.escape_html(user_prompt_display)
base = ""
if user_prompt_display:
base = self.strings("resp_base_media").format(
media_type=media_type_str, prompt=escaped
) if media_type_str else self.strings("resp_base").format(prompt=escaped)
elif media_type_str:
base = self.strings("resp_base_media_only").format(media_type=media_type_str)
final = f"{base}\n{response}"
uploaded_name = uploaded_file.name if uploaded_file else None
prompt_text = content_parts[-1] if content_parts and isinstance(content_parts[-1], str) else ""
regen_args = (prompt_text, uploaded_name, media_type_str, user_prompt_display, api_key)
if len(final.encode('utf-8')) > 3996:
pages = await utils.run_sync(self._get_pages, html)
if pages:
await self._paginate_response_callback(
call=None,
message=status_msg,
pages=pages,
current_page_index=0,
constructed_base_text=base,
regenerate_callback=self._regenerate_stateless_callback,
regenerate_args=regen_args
)
else:
buttons = [[{
"text": self.strings("btn_regenerate"),
"callback": self._regenerate_stateless_callback,
"args": regen_args,
}]]
await self.inline.form(
message=status_msg,
text=f"{base}\n{self.strings('pagination_error')}",
reply_markup=buttons,
silent=True,
)
else:
buttons = [[{
"text": self.strings("btn_regenerate"),
"callback": self._regenerate_stateless_callback,
"args": regen_args,
}]]
await self.inline.form(
message=status_msg,
text=final,
reply_markup=buttons,
silent=True,
)
except (RuntimeError, ValueError) as e:
err = self.strings("api_error").format(error=utils.escape_html(str(e)))
await utils.answer(status_msg, err)
except Exception as e:
err = self.strings("unknown_error").format(error=utils.escape_html(str(e)))
await utils.answer(status_msg, err)
def _get_pages(self, html_text: str) -> List[str]:
text_content, entities = html.parse(html_text)
parts = split_text(text_content, entities, limit=3000)
pages = [html.unparse(part_text, part_entities) for part_text, part_entities in parts]
pages = [p.replace("", "") for p in pages] #love telethonn👹 return pages async def _paginate_response_callback( self, call: Optional[InlineCall], message: Optional[Message], pages: List[str], current_page_index: int, constructed_base_text: str, regenerate_callback: callable, regenerate_args: tuple, ): total = len(pages) idx = max(0, min(total - 1, current_page_index)) page = pages[idx] text = f"{constructed_base_text}\n{page}" buttons = [] nav = [] if idx > 0: nav.append({ "text": self.strings("btn_prev"), "callback": self._paginate_response_callback, "args": (None, pages, idx - 1, constructed_base_text, regenerate_callback, regenerate_args), }) if idx < total - 1: nav.append({ "text": self.strings("btn_next"), "callback": self._paginate_response_callback, "args": (None, pages, idx + 1, constructed_base_text, regenerate_callback, regenerate_args), }) if nav: buttons.append(nav) buttons.append([{ "text": self.strings("btn_regenerate"), "callback": regenerate_callback, "args": regenerate_args, }]) if call: await call.edit(text=text, reply_markup=buttons) elif message: await self.inline.form( message=message, text=text, reply_markup=buttons, silent=True ) else: logger.error("Pagination callback received neither call nor message") async def _display_chats_list(self, target: Union[Message, InlineCall]): chats = self._get_chats() is_call = isinstance(target, InlineCall) if not chats: text = self.strings("chat_no_chats").format(prefix=self.get_prefix()) if is_call: await target.edit(text=text, reply_markup=None) else: await utils.answer(target, text) return text = self.strings("chat_list_title") buttons = [] sorted_ids = sorted(chats.keys(), key=lambda k: chats[k].get("name", "").lower()) for chat_id in sorted_ids: chat = chats[chat_id] name = chat.get("name", f"Chat-{chat_id[:4]}") buttons.append([{ "text": self.strings("btn_chat").format(name=utils.escape_html(name), chat_id=chat_id), "callback": self._chat_manage_menu, "args": (chat_id,), }]) if is_call: await target.edit(text=text, reply_markup=buttons) else: await self.inline.form(message=target, text=text, reply_markup=buttons, silent=True) async def _chat_manage_menu(self, call: InlineCall, chat_id: str): chats = self._get_chats() if chat_id not in chats: await call.answer(self.strings("chat_not_found").format(chat_id=chat_id), show_alert=True) await self._display_chats_list(call) return name = chats[chat_id].get("name", f"Chat-{chat_id[:4]}") buttons = [ [{"text": self.strings("chat_menu_history"), "callback": self._chat_view_history, "args": (chat_id, 0)}], [{"text": self.strings("chat_menu_delete"), "callback": self._chat_delete_confirm, "args": (chat_id,)}], [{"text": self.strings("chat_menu_back"), "callback": self._display_chats_list}], ] text = self.strings("chat_menu_title").format(chat_id=chat_id, name=utils.escape_html(name)) await call.edit(text=text, reply_markup=buttons) async def _chat_view_history(self, call: InlineCall, chat_id: str, page_index: int): chats = self._get_chats() if chat_id not in chats or not isinstance(chats[chat_id].get("history"), list): await call.answer(self.strings("chat_not_found").format(chat_id=chat_id), show_alert=True) await self._display_chats_list(call) return chat = chats[chat_id] name = chat.get("name", f"Chat-{chat_id[:4]}") history = chat.get("history", []) if not history: btns = [[{"text": self.strings("chat_menu_back"), "callback": self._chat_manage_menu, "args": (chat_id,)}]] await call.edit(text=self.strings("chat_hist_empty"), reply_markup=btns) return entries = [] for e in history: role = "👤 User" if e.get("role") == "user" else "✨ Gemini" parts = e.get("parts", []) if not isinstance(parts, list): parts = [str(parts)] text = utils.escape_html(" ".join(str(p) for p in parts)).strip() or "[empty]" entries.append(self.strings("chat_hist_entry").format(role=role, text=text)) html = "\n\n".join(entries) pages = await utils.run_sync(self._get_pages, html) total = len(pages) idx = max(0, min(total - 1, page_index)) if not pages: err = self.strings("chat_hist_empty") if history: err += f"\n{self.strings('pagination_error')}" btns = [[{"text": self.strings("chat_menu_back"), "callback": self._chat_manage_menu, "args": (chat_id)}]] await call.edit(text=err, reply_markup=btns) return page = pages[idx] title = self.strings("chat_hist_title").format( chat_id=chat_id, name=utils.escape_html(name), current_page=idx + 1, total_pages=total ) text = f"{title}\n\n{page}" nav = [] if idx > 0: nav.append({"text": self.strings("btn_prev"), "callback": self._chat_view_history, "args": (chat_id, idx - 1)}) if idx < total - 1: nav.append({"text": self.strings("btn_next"), "callback": self._chat_view_history, "args": (chat_id, idx + 1)}) btns = [] if nav: btns.append(nav) btns.append([{"text": self.strings("chat_menu_back"), "callback": self._chat_manage_menu, "args": (chat_id)}]) await call.edit(text=text, reply_markup=btns) async def _chat_delete_confirm(self, call: InlineCall, chat_id: str): chats = self._get_chats() if chat_id not in chats: await call.answer(self.strings("chat_not_found").format(chat_id=chat_id), show_alert=True) await self._display_chats_list(call) return name = chats[chat_id].get("name", f"Chat-{chat_id[:4]}") buttons = [[ {"text": self.strings("chat_del_yes"), "callback": self._chat_delete_execute, "args": (chat_id,)}, {"text": self.strings("chat_del_no"), "callback": self._chat_manage_menu, "args": (chat_id,)}, # Back to chat menu ]] text = self.strings("chat_del_confirm").format(chat_id=chat_id, name=utils.escape_html(name)) await call.edit(text=text, reply_markup=buttons) async def _chat_delete_execute(self, call: InlineCall, chat_id: str): chats = self._get_chats() if chat_id in chats: del chats[chat_id] self._save_chats(chats) await call.answer(self.strings("chat_del_ok").format(chat_id=chat_id), show_alert=True) await self._display_chats_list(call) else: await call.answer(self.strings("chat_not_found").format(chat_id=chat_id), show_alert=True) await self._display_chats_list(call) async def _upload_media( self, status_msg: Message, dl_func: callable, mime_type: str, media_type_str: str ) -> Tuple[Optional[genai.types.File], Optional[str]]: api_key = self._get_api_key() if not api_key: await utils.answer(status_msg, self.strings("no_keys").format(prefix=self.get_prefix())) return None, None uploaded_file = None temp_file_path = None try: with tempfile.NamedTemporaryFile(delete=False, suffix=f".{mime_type.split('/')[-1]}") as temp_file: temp_file_path = temp_file.name await utils.answer(status_msg, self.strings("media_dl").format(media_type=media_type_str)) await dl_func(temp_file_path) await utils.answer(status_msg, self.strings("media_upload").format(media_type=media_type_str)) genai.configure(api_key=api_key) uploaded_file = await utils.run_sync( genai.upload_file, path=temp_file_path, display_name=f"hikka_{media_type_str}_{secrets.token_hex(3)}", mime_type=mime_type ) await self._wait_for_file_active(uploaded_file, api_key) except Exception as e: err_key = "media_upload_error" if 'upload' in str(e).lower() or 'state' in str(e).lower() else "media_dl_error" await utils.answer(status_msg, self.strings(err_key).format(media_type=media_type_str, error=utils.escape_html(str(e)))) uploaded_file = None api_key = None finally: if temp_file_path and os.path.exists(temp_file_path): try: os.remove(temp_file_path) except Exception as e_rm: logger.error(f"Failed to remove temp file {temp_file_path}: {e_rm}") return uploaded_file, api_key def _get_api_key(self) -> Optional[str]: keys = self.config["api_key"] return random.choice(keys) if keys else None @staticmethod def markdown2html(text: str) -> str: text = re.sub(r"\*\*(.*?)\*\*", r"\1", text) text = re.sub(r"\*(.*?)\*", r"\1", text) text = re.sub(r"```(?:\w*\n)?(.*?)```", r"\1", text, flags=re.DOTALL) text = re.sub(r"``(.*?)``", r"\1", text) text = re.sub(r"`(.*?)`", r"\1", text) text = re.sub(r"^\s*[\*\-]\s+(.*)", r"• \1", text, flags=re.MULTILINE) text = re.sub(r"\[(.*?)\]\((.*?)\)", r'\1', text) allowed_tags = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'span', 'tg-spoiler', 'a', 'code','pre', 'blockquote'] def filter_tags(match): tag = match.group(1).lower() attrs = match.group(2) or '' if tag.strip('/').split()[0] in allowed_tags: if 'span' in tag and 'tg-spoiler' not in attrs: return '' if 'code' in tag and 'language-' in attrs: return f"<{tag}{attrs}>" elif tag.startswith('/'): return f"<{tag}>" elif any(attr in attrs for attr in ['href=', 'class=', 'emoji-id=']): return f"<{tag}{attrs}>" else: return f"<{tag}>" return '' text = re.sub(r"<(/?\w+)([^>]*)>", filter_tags, text) return text def make_expandable(self, html_content: str) -> str: if len(html_content) > 500: return f"{html_content}" return html_content def _format_quote(self, message: Message) -> str: if message.reply_to and message.reply_to.quote_text: quote = f'"{message.reply_to.quote_text}"' return quote else: return None def _get_media_info(self, message: Message) -> Optional[Tuple[callable, str, str]]: if not message or not message.media: return None doc = getattr(message.media, 'document', None) if doc and getattr(doc, 'mime_type', None): mt = doc.mime_type if mt in ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]: t = self.strings("photo") elif mt in ["video/mp4", "video/mpeg", "video/mov", "video/avi", "video/x-flv", "video/mpg", "video/webm", "video/wmv", "video/3gpp"]: t = self.strings("video") elif mt in ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"]: t = self.strings("audio") else: t = None if t: return lambda f: self.client.download_media(doc, file=f), mt, t photo = getattr(message.media, 'photo', None) if photo: return lambda f: self.client.download_media(photo, file=f), "image/jpeg", self.strings("photo") for attr, types, label in [ ('video', ["video/mp4", "video/mpeg", "video/mov", "video/avi", "video/x-flv", "video/mpg", "video/webm", "video/wmv", "video/3gpp"], self.strings("video")), ('voice', ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"], self.strings("voice")), ('audio', ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"], self.strings("audio")), ]: media = getattr(message.media, attr, None) if media: mt = getattr(media, 'mime_type', None) if mt and mt in types: return lambda f, m=media: self.client.download_media(m, file=f), mt, label if attr == 'video': return lambda f, m=media: self.client.download_media(m, file=f), 'video/mp4', self.strings("video") if attr == 'voice': return lambda f, m=media: self.client.download_media(m, file=f), 'audio/ogg', self.strings("voice") if attr == 'audio': return lambda f, m=media: self.client.download_media(m, file=f), 'audio/mpeg', self.strings("audio") return None async def _wait_for_file_active(self, file_object: genai.types.File, api_key: str): genai.configure(api_key=api_key) while True: try: updated = await utils.run_sync(genai.get_file, name=file_object.name) if updated.state.name == "ACTIVE": return True elif updated.state.name == "FAILED": raise RuntimeError(f"File {file_object.name} processing failed on Google's side.") await asyncio.sleep(0.4) except Exception as e: raise RuntimeError(f"Error checking file {file_object.name} status: {e}")