diff --git a/fajox1/famods/assets/banners/cocoon.png b/fajox1/famods/assets/banners/cocoon.png new file mode 100644 index 0000000..93ab3a4 Binary files /dev/null and b/fajox1/famods/assets/banners/cocoon.png differ diff --git a/fajox1/famods/cocoon.py b/fajox1/famods/cocoon.py new file mode 100644 index 0000000..fd5ce3b --- /dev/null +++ b/fajox1/famods/cocoon.py @@ -0,0 +1,388 @@ +# █▀▀ ▄▀█   █▀▄▀█ █▀█ █▀▄ █▀ +# █▀░ █▀█   █░▀░█ █▄█ █▄▀ ▄█ + +# https://t.me/famods + +# 🔒 Licensed under the GNU AGPLv3 +# 🌐 https://www.gnu.org/licenses/agpl-3.0.html + +# --------------------------------------------------------------------------------- +# Name: Cocoon [BETA] +# 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 +# --------------------------------------------------------------------------------- + +import html +import httpx +import asyncio +import logging + +from openai import AsyncOpenAI +from typing import Optional, Any +from dataclasses import dataclass +from datetime import datetime, timezone, timedelta + +from telethon.errors import MessageNotModifiedError + +from .. import loader, utils + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Usage: + spent_nano: int + spent_ton: str + tokens_spent: int + free_tokens_remaining: int + free_tokens_reset_at: Optional[int] + updated_at: Optional[int] + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _safe_int(v: Any, default: int = 0) -> int: + try: + return int(v) + except Exception: + return default + + +def _escape_text(s: str) -> str: + return html.escape(s or "", quote=False) + + +def _days_until_reset(reset_ts: Optional[int], cycle_days: int = 30) -> int: + """ + Cocoon может отдавать free_tokens_reset_at как момент сброса текущего периода, + который уже мог произойти сегодня. Тогда следующий сброс = reset_ts + cycle_days. + """ + if not reset_ts: + return cycle_days + + try: + now = _now_utc() + target = datetime.fromtimestamp(int(reset_ts), tz=timezone.utc) + + if target <= now: + target = target + timedelta(days=cycle_days) + + delta = target - now + if delta.total_seconds() <= 0: + return 0 + + return max(delta.days + (1 if delta.seconds > 0 else 0), 0) + except Exception: + return cycle_days + + +def _normalize_reset_ts(reset_at: Optional[int]) -> Optional[int]: + if not reset_at: + return None + + reset_at = _safe_int(reset_at, 0) + if reset_at <= 0: + return None + + return reset_at + + +def _format_compact(n: int) -> str: + n = int(n) + + if n >= 1_000_000_000: + return f"{n / 1_000_000_000:.1f}".rstrip("0").rstrip(".") + "b" + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}".rstrip("0").rstrip(".") + "m" + if n >= 1_000: + return f"{n / 1_000:.1f}".rstrip("0").rstrip(".") + "k" + return str(n) + + +def _percent_remaining(spent: int, total: int) -> float: + if total <= 0: + return 0.0 + remaining = max(total - spent, 0) + return (remaining / total) * 100.0 + + +@loader.tds +class Cocoon(loader.Module): + """Взаимодействие с Cocoon от HikkaHost""" + + strings = { + "name": "Cocoon [BETA]", + "try_again": " Что-то пошло не так. Попробуйте снова.", + "no_args": " Нужно {}{} {}", + "no_token": ( + " Нету токена! Вставь его в {}cfg cocoon\n\n" + "🌘 Получить токен: @hikkahost_bot → 🥚 Cocoon" + ), + "invalid_token_or_no_sub": ( + "Неверный токен или у вас нет подписки 🌘 HikkaHost.\n\n" + "🌘 Получить токен: @hikkahost_bot → 🥚 Cocoon" + ), + "sending_request_to_cocoon": "🐣 Обрабатываю запрос в Cocoon...", + "thinking": ( + "🐣 Думаю...\n\n" + "
{thoughts}…
" + ), + "answer": ( + "🌘 Вопрос: {question}\n\n" + "🐣 Размышления:\n" + "
{thoughts}
\n\n" + "🥚 {answer}\n\n" + "🚀 Модель: {model}" + ), + "usage": ( + "🥚 Cocoon API\n\n" + "💡 Использовано:\n" + "• {current}/{total} ({percent}% осталось)\n\n" + " Лимит сбросится через {days} день(-ей)." + ), + } + + def __init__(self): + self.config = loader.ModuleConfig( + loader.ConfigValue( + "token", + None, + lambda: "Токен HikkaHost API. Получить токен: @hikkahost_bot -> 🥚 Cocoon", + validator=loader.validators.Hidden(loader.validators.String()), + ), + loader.ConfigValue( + "model", + "Qwen/Qwen3-32B", + lambda: "Модель ИИ. Список: https://cocoon.hikka.host/v1/models", + ), + loader.ConfigValue( + "role", + "user", + lambda: "Роль user-сообщения (обычно user).", + ), + loader.ConfigValue( + "system_prompt", + "", + lambda: "System prompt (инструкция для модели, role=system).", + ), + loader.ConfigValue( + "max_tokens", + 3900, + lambda: "Максимальное количество токенов для ответа модели.", + validator=loader.validators.Integer(minimum=1), + ), + loader.ConfigValue( + "temperature", + 0.2, + lambda: "Температура генерации (0.0–1.0).", + ), + ) + + async def client_ready(self, client, db): + self.db = db + self._client = client + + self.api_url = "https://cocoon.hikka.host/v1" + self._openai: Optional[AsyncOpenAI] = None + + def _rebuild_openai_client(self) -> None: + token = self.config.get("token") or "" + self._openai = AsyncOpenAI( + api_key=token, base_url=self.api_url, timeout=60.0, max_retries=2 + ) + + def _ensure_client(self) -> None: + if not self._openai or ( + self._openai.api_key != (self.config.get("token") or "") + ): + self._rebuild_openai_client() + + async def _answer(self, message, text): + try: + if len(text) > 4096: + text = text[:4090] + "..." + return await utils.answer(message, text) + except MessageNotModifiedError: + return message + + async def _fetch_usage(self) -> Optional[Usage]: + token = self.config.get("token") + if not token: + return None + + headers = { + "accept": "application/json", + "X-API-Key": token, + } + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(f"{self.api_url}/usage", headers=headers) + r.raise_for_status() + data = r.json() + except Exception as e: + logger.exception("Usage request failed: %s", e) + return None + + if isinstance(data, dict) and data.get("detail") == "API key not recognized": + return None + + if not isinstance(data, dict): + return None + + return Usage( + spent_nano=_safe_int(data.get("spent_nano"), 0), + spent_ton=str(data.get("spent_ton", "0")), + tokens_spent=_safe_int(data.get("tokens_spent"), 0), + free_tokens_remaining=_safe_int(data.get("free_tokens_remaining"), 0), + free_tokens_reset_at=( + _safe_int(data.get("free_tokens_reset_at"), 0) or None + ), + updated_at=(_safe_int(data.get("updated_at"), 0) or None), + ) + + @loader.command() + async def ccusage(self, message): + """Статистика использования Cocoon""" + + if not self.config.get("token"): + return await self._answer( + message, self.strings["no_token"].format(self.get_prefix()) + ) + + usage = await self._fetch_usage() + if not usage: + return await self._answer(message, self.strings["invalid_token_or_no_sub"]) + + reset_ts = _normalize_reset_ts(usage.free_tokens_reset_at) + days = _days_until_reset(reset_ts, cycle_days=30) + + spent = usage.tokens_spent + total = spent + usage.free_tokens_remaining + if total <= 0: + total = 1_000_000 + + percent = _percent_remaining(spent, total) + percent_fmt = f"{percent:.1f}".rstrip("0").rstrip(".") + + return await self._answer( + message, + self.strings["usage"].format( + current=_format_compact(spent), + total=_format_compact(total), + percent=percent_fmt, + days=days, + ), + ) + + @loader.command() + async def cocoon(self, message): + """Задать вопрос к ИИ""" + + q = utils.get_args_raw(message) + if not q: + return await utils.answer( + message, + self.strings["no_args"].format(self.get_prefix(), "cocoon", "[вопрос]"), + ) + + if not self.config["token"]: + return await utils.answer( + message, self.strings["no_token"].format(self.get_prefix()) + ) + + usage = await self._fetch_usage() + 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"]) + + 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}) + + try: + response = await client.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) + ) + + response_text = "" + chunk_buffer = "" + + async for chunk in response: + if chunk.choices and chunk.choices[0].delta.content: + chunk_buffer += chunk.choices[0].delta.content + + if len(chunk_buffer) >= 150: + response_text += chunk_buffer + chunk_buffer = "" + + thoughts = ( + response_text.replace("", "") + .replace("", "") + .strip() + ) + + if "" in response_text: + after_think = response_text.split("", 1)[-1].strip() + await self._answer( + message, + self.strings["answer"].format( + thoughts=thoughts[:300], + question=q, + answer=_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) + ), + ) + + await asyncio.sleep(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() + ) + + await self._answer( + message, + self.strings["answer"].format( + thoughts=thoughts, + question=q, + answer=_escape_text(after_think), + model=self.config["model"], + ), + ) + + except httpx.RemoteProtocolError: + return await self._answer(message, self.strings["try_again"]) diff --git a/modules.json b/modules.json index df3719c..9a6b06f 100644 --- a/modules.json +++ b/modules.json @@ -22698,6 +22698,67 @@ "has_on_unload": false, "class_cmd_names": {} }, + "fajox1/famods/cocoon.py": { + "name": "Cocoon", + "description": "Взаимодействие с Cocoon от HikkaHost", + "cls_doc": {}, + "meta": { + "pic": null, + "banner": "https://github.com/FajoX1/FAmods/blob/main/assets/banners/cocoon.png?raw=true", + "developer": "@FAmods & @vsecoder_m" + }, + "commands": [ + { + "ccusage": "Статистика использования Cocoon" + }, + { + "cocoon": "Задать вопрос к ИИ" + } + ], + "new_commands": [ + { + "name": "ccusage", + "original_name": "ccusage", + "description": { + "default": "Статистика использования Cocoon" + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + }, + { + "name": "cocoon", + "original_name": "cocoon", + "description": { + "default": "Задать вопрос к ИИ" + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + } + ], + "inline_handlers": [], + "strings": { + "name": "Cocoon [BETA]", + "try_again": " Что-то пошло не так. Попробуйте снова.", + "no_args": " Нужно {}{} {}", + "no_token": " Нету токена! Вставь его в {}cfg cocoon\n\n🌘 Получить токен: @hikkahost_bot → 🥚 Cocoon", + "invalid_token_or_no_sub": "Неверный токен или у вас нет подписки 🌘 HikkaHost.\n\n🌘 Получить токен: @hikkahost_bot → 🥚 Cocoon", + "sending_request_to_cocoon": "🐣 Обрабатываю запрос в Cocoon...", + "thinking": "🐣 Думаю...\n\n
{thoughts}…
", + "answer": "🌘 Вопрос: {question}\n\n🐣 Размышления:\n
{thoughts}
\n\n🥚 {answer}\n\n🚀 Модель: {model}", + "usage": "🥚 Cocoon API\n\n💡 Использовано:\n• {current}/{total} ({percent}% осталось)\n\n Лимит сбросится через {days} день(-ей)." + }, + "has_on_load": false, + "has_on_unload": false, + "class_cmd_names": {} + }, "fajox1/famods/ytsearch.py": { "name": "YTsearch", "description": "Поиск в Youtube", @@ -82017,7 +82078,7 @@ } }, "meta": { - "total_modules": 1019, - "generated_at": "2026-02-21T01:23:09.755369" + "total_modules": 1020, + "generated_at": "2026-02-25T01:29:20.799711" } } \ No newline at end of file