diff --git a/coddrago/modules/dbmod.py b/coddrago/modules/dbmod.py index a8f69c9..42d0cfd 100644 --- a/coddrago/modules/dbmod.py +++ b/coddrago/modules/dbmod.py @@ -13,7 +13,7 @@ class DBMod(loader.Module): "close_btn": "❌ Close", "back_btn": "⬅ Back", "del_btn": "🗑 Delete", - "del_all_btn": "💣 Delete all", + "del_all_btn": "❌ Delete all", "not_found": "🔍 Key {key} not found", "invalid_key": "⚠ Invalid key", "page": "📄 Page {current}/{total}", @@ -34,7 +34,7 @@ class DBMod(loader.Module): "close_btn": "❌ Закрыть", "back_btn": "⬅ Назад", "del_btn": "🗑 Удалить", - "del_all_btn": "💣 Удалить все", + "del_all_btn": "❌ Удалить все", "not_found": "🔍 Ключ {key} не найден", "invalid_key": "⚠ Некорректный ключ", "page": "📄 Страница {current}/{total}", @@ -240,6 +240,7 @@ class DBMod(loader.Module): { "text": self.strings["del_all_btn"], "callback": self.confirm_delete_all, + "style": "danger", "args": [key_path], } ] @@ -290,6 +291,7 @@ class DBMod(loader.Module): { "text": self.strings["back_btn"], "callback": self.navigate_db, + "style": "primary", "args": [key_path[:-1], parent_page], } ) @@ -329,6 +331,7 @@ class DBMod(loader.Module): { "text": self.strings["del_all_btn"], "callback": self.confirm_delete_all, + "style": "danger", "args": [key_path], } ] @@ -344,13 +347,16 @@ class DBMod(loader.Module): { "text": self.strings["del_btn"], "callback": self.delete_key, + "styles": "danger", "args": [key_path], } ], [ { "text": self.strings["back_btn"], + "style": "primary", "callback": self.navigate_db, + "style": "primary", "args": [key_path[:-1], parent_page], } ], @@ -363,6 +369,7 @@ class DBMod(loader.Module): { "text": self.strings["del_btn"], "callback": self.delete_key, + "style": "danger", "args": [key_path], } ], @@ -370,6 +377,7 @@ class DBMod(loader.Module): { "text": self.strings["back_btn"], "callback": self.navigate_db, + "style": "primary", "args": [key_path[:-1], parent_page], } ], diff --git a/fajox1/famods/cocoon.py b/fajox1/famods/cocoon.py index fd5ce3b..aff16ad 100644 --- a/fajox1/famods/cocoon.py +++ b/fajox1/famods/cocoon.py @@ -11,26 +11,54 @@ # Description: Взаимодействие с Cocoon от HikkaHost # meta developer: @FAmods & @vsecoder_m # meta banner: https://github.com/FajoX1/FAmods/blob/main/assets/banners/cocoon.png?raw=true -# requires: openai httpx aiohttp +# requires: openai httpx aiohttp bs4 markdown # --------------------------------------------------------------------------------- +import re import html +import uuid import httpx import asyncio import logging +import markdown from openai import AsyncOpenAI from typing import Optional, Any from dataclasses import dataclass +from bs4 import BeautifulSoup, NavigableString from datetime import datetime, timezone, timedelta +from telethon.tl.types import User from telethon.errors import MessageNotModifiedError +from aiogram.exceptions import TelegramBadRequest + from .. import loader, utils +from ..inline.types import InlineCall logger = logging.getLogger(__name__) +TG_ALLOWED = { + "b", + "strong", + "i", + "em", + "u", + "ins", + "s", + "strike", + "del", + "a", + "code", + "pre", + "blockquote", + "emoji", + "tg-emoji", +} +TAG_MAP = {"strong": "b", "em": "i", "del": "s", "strike": "s", "ins": "u"} + + @dataclass(frozen=True) class Usage: spent_nano: int @@ -110,6 +138,92 @@ def _percent_remaining(spent: int, total: int) -> float: return (remaining / total) * 100.0 +def md_to_tg_html(text: str) -> str: + if not text: + return "" + + raw_html = markdown.markdown(text, extensions=["fenced_code", "tables", "nl2br"]) + soup = BeautifulSoup(raw_html, "html.parser") + + def stringify(node, lang=None): + res = "" + + for child in node.children: + if isinstance(child, NavigableString): + res += html.escape(str(child)) + + elif child.name: + tag_name = child.name + + if tag_name in ["h1", "h2", "h3", "h4", "h5", "h6"]: + content = stringify(child) + res += f"{content}\n\n" + elif tag_name == "p": + res += stringify(child) + "\n\n" + elif tag_name == "br": + res += "\n" + elif tag_name == "li": + res += f"• {stringify(child)}\n" + elif tag_name in ["ul", "ol"]: + res += stringify(child) + "\n" + elif tag_name == "tr": + res += "| " + stringify(child) + "\n" + elif tag_name in ["td", "th"]: + res += stringify(child) + " | " + + else: + target_tag = TAG_MAP.get(tag_name, tag_name) + + if target_tag in TG_ALLOWED: + inner_html = stringify(child) + + if not inner_html.strip() and target_tag not in ["code", "pre"]: + res += inner_html + continue + + if target_tag == "a": + href = child.get("href", "") + if href: + res += f'{inner_html}' + else: + res += inner_html + elif target_tag == "code": + cls = child.get("class", []) + if cls and cls[0].startswith("language-"): + res += f'{inner_html}' + else: + res += f"{inner_html}" + elif target_tag == "pre": + res += f"
{inner_html}
" + else: + res += f"<{target_tag}>{inner_html}" + else: + res += stringify(child) + return res + + final_text = stringify(soup) + + final_text = re.sub(r"\n{3,}", "\n\n", final_text) + return final_text.strip() + + +def repair_html_tags(html_chunk: str) -> str: + if not html_chunk: + return "" + + newline_placeholder = f"MARKER_{uuid.uuid4().hex}" + + protected_html = html_chunk.replace("\n", newline_placeholder) + + soup = BeautifulSoup(protected_html, "html.parser") + + repaired_html = soup.decode_contents(formatter=None) + + final_html = repaired_html.replace(newline_placeholder, "\n") + + return final_html + + @loader.tds class Cocoon(loader.Module): """Взаимодействие с Cocoon от HikkaHost""" @@ -126,24 +240,25 @@ class Cocoon(loader.Module): "Неверный токен или у вас нет подписки 🌘 HikkaHost.\n\n" "🌘 Получить токен: @hikkahost_bot → 🥚 Cocoon" ), - "sending_request_to_cocoon": "🐣 Обрабатываю запрос в Cocoon...", + "sending_request_to_cocoon": "🐣 Обрабатываю запрос в Cocoon...", "thinking": ( - "🐣 Думаю...\n\n" + "🐣 Думаю...\n\n" "
{thoughts}…
" ), "answer": ( - "🌘 Вопрос: {question}\n\n" - "🐣 Размышления:\n" + "🌘 Вопрос: {question}\n\n" + "🐣 Размышления:\n" "
{thoughts}
\n\n" - "🥚 {answer}\n\n" - "🚀 Модель: {model}" + "🥚 {answer}\n\n" + "🚀 Модель: {model}" ), "usage": ( - "🥚 Cocoon API\n\n" - "💡 Использовано:\n" + "🥚 Cocoon API\n\n" + "💡 Использовано:\n" "• {current}/{total} ({percent}% осталось)\n\n" - " Лимит сбросится через {days} день(-ей)." + " Лимит сбросится через {days} день(-ей)." ), + "again_kb": "🔄 Сгенерировать ещё раз", } def __init__(self): @@ -163,6 +278,9 @@ class Cocoon(loader.Module): "role", "user", lambda: "Роль user-сообщения (обычно user).", + validator=loader.validators.Choice( + ["system", "developer", "user", "assistant", "tool"] + ), ), loader.ConfigValue( "system_prompt", @@ -201,12 +319,13 @@ class Cocoon(loader.Module): ): self._rebuild_openai_client() - async def _answer(self, message, text): + async def _answer(self, message, text, *args, **kwargs): try: if len(text) > 4096: text = text[:4090] + "..." - return await utils.answer(message, text) - except MessageNotModifiedError: + + return await utils.answer(message, repair_html_tags(text), *args, **kwargs) + except (MessageNotModifiedError, TelegramBadRequest): return message async def _fetch_usage(self) -> Optional[Usage]: @@ -245,6 +364,9 @@ class Cocoon(loader.Module): updated_at=(_safe_int(data.get("updated_at"), 0) or None), ) + async def _regenerate(self, call: InlineCall, arg1, arg2): + await self.cocoon(arg1, inline_message=arg2) + @loader.command() async def ccusage(self, message): """Статистика использования Cocoon""" @@ -280,8 +402,8 @@ class Cocoon(loader.Module): ) @loader.command() - async def cocoon(self, message): - """Задать вопрос к ИИ""" + async def cocoon(self, message, inline_message=None): + """Задать вопрос к ИИ (поддерживает ответ на сообщение)""" q = utils.get_args_raw(message) if not q: @@ -299,26 +421,72 @@ class Cocoon(loader.Module): if not usage: return await utils.answer(message, self.strings["invalid_token_or_no_sub"]) - message = await utils.answer(message, self.strings["sending_request_to_cocoon"]) + user_message = message + + if not inline_message: + message = await self.inline.form( + text="...", + message=message, + force_me=False, + ) + else: + message = inline_message + + await utils.answer(message, self.strings["sending_request_to_cocoon"]) self._ensure_client() - client = AsyncOpenAI(api_key=self.config["token"], base_url=self.api_url) - system_prompt = (self.config.get("system_prompt") or "").strip() messages = [] if system_prompt: messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": self.config["role"] or "user", "content": q}) + + if user_message.reply_to: + reply = await user_message.get_reply_message() + + entity_id = None + + if hasattr(reply, "from_id") and reply.from_id: + if hasattr(reply.from_id, "user_id") and reply.from_id.user_id: + entity_id = reply.from_id.user_id + elif hasattr(reply.from_id, "channel_id") and reply.from_id.channel_id: + entity_id = reply.from_id.channel_id + + if entity_id is None: + if hasattr(reply.peer_id, "user_id") and reply.peer_id.user_id: + entity_id = reply.peer_id.user_id + else: + entity_id = reply.peer_id.channel_id + + entity = await self.client.get_entity(entity_id) + + date = reply.date.strftime("%H:%M %d.%m.%Y UTC") + + messages.append( + { + "role": "user", + "content": ( + ( + f"{entity.first_name} {entity.last_name or ''} (user id: {entity.id}) " + if isinstance(entity, User) + else f"Channel {entity.title} (channel id: {entity.id}) " + ) + + f"msg id: {reply.id}, date: {date}: " + + reply.message + ), + } + ) + + messages.append({"role": self.config.get("role", "user"), "content": q}) try: - response = await client.chat.completions.create( + response = await self._openai.chat.completions.create( messages=messages, stream=True, max_tokens=self.config.get("max_tokens", 3900), model=self.config.get("model", "Qwen/Qwen3-32B"), - temperature=self.config.get("temperature", 0.2) + temperature=self.config.get("temperature", 0.2), ) response_text = "" @@ -328,60 +496,57 @@ class Cocoon(loader.Module): if chunk.choices and chunk.choices[0].delta.content: chunk_buffer += chunk.choices[0].delta.content - if len(chunk_buffer) >= 150: + if len(chunk_buffer) >= 100: response_text += chunk_buffer chunk_buffer = "" - thoughts = ( - response_text.replace("", "") - .replace("", "") - .strip() + thoughts = response_text.split("", 1)[0].replace( + "", "" ) if "" in response_text: - after_think = response_text.split("", 1)[-1].strip() + after_think = response_text.split("", 1)[1].strip() await self._answer( message, self.strings["answer"].format( - thoughts=thoughts[:300], + thoughts=thoughts[:500], question=q, - answer=_escape_text(after_think) + "…", + answer=md_to_tg_html(_escape_text(after_think) + "…"), model=self.config["model"], ), ) else: - thinking_text = ( - response_text.replace("", "") - .replace("", "") - .strip() - ) await self._answer( message, self.strings["thinking"].format( - thoughts=_escape_text(thinking_text) + thoughts=md_to_tg_html(_escape_text(thoughts) + "…") ), ) - await asyncio.sleep(2) + await asyncio.sleep(0.2) if chunk_buffer: response_text += chunk_buffer - if "" in response_text: - after_think = response_text.split("", 1)[-1].strip() - else: - after_think = ( - response_text.replace("", "").replace("", "").strip() - ) + responses_data = response_text.split("", 1) + thoughts = responses_data[0].strip().replace("", "") + after_think = responses_data[1].strip() await self._answer( message, self.strings["answer"].format( - thoughts=thoughts, question=q, - answer=_escape_text(after_think), + thoughts=md_to_tg_html(_escape_text(thoughts[:500])), + answer=md_to_tg_html(_escape_text(after_think)), model=self.config["model"], ), + reply_markup=[ + { + "text": self.strings["again_kb"], + "callback": self._regenerate, + "args": [user_message, message], + } + ], ) except httpx.RemoteProtocolError: diff --git a/fajox1/famods/full.txt b/fajox1/famods/full.txt index b4be147..16877a3 100644 --- a/fajox1/famods/full.txt +++ b/fajox1/famods/full.txt @@ -43,4 +43,5 @@ evalaliases spotify4ik picme hetsu -ptichki \ No newline at end of file +ptichki +cocoon