Add bot administration commands
This commit is contained in:
@@ -0,0 +1,737 @@
|
|||||||
|
"""
|
||||||
|
Admin & user commands.
|
||||||
|
Owner-only commands are guarded by is_owner(); auth-user commands by is_authorized().
|
||||||
|
"""
|
||||||
|
from html import escape
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.constants import ChatAction, ParseMode
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
from services import auth, ai_client
|
||||||
|
from utils import context, chunker, formatter
|
||||||
|
from config import (
|
||||||
|
BOT_VERSION, MAX_TOKENS_DEFAULT, MAX_TOKENS_MIN, MAX_TOKENS_MAX,
|
||||||
|
DEFAULT_SYSTEM_PROMPT, IMAGE_DAILY_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _owner_required(func):
|
||||||
|
"""Decorator: silently skip if caller is not the owner."""
|
||||||
|
async def wrapper(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
u = update.effective_user
|
||||||
|
if not auth.is_owner(u.id, u.username or ""):
|
||||||
|
return
|
||||||
|
return await func(update, ctx)
|
||||||
|
wrapper.__name__ = func.__name__
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_required(func):
|
||||||
|
"""Decorator: silently skip if caller is not authorized (respects public mode)."""
|
||||||
|
async def wrapper(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
u = update.effective_user
|
||||||
|
if not auth.is_authorized(u.id, u.username or ""):
|
||||||
|
return
|
||||||
|
auth.register_known(u.id)
|
||||||
|
return await func(update, ctx)
|
||||||
|
wrapper.__name__ = func.__name__
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE, text: str):
|
||||||
|
await ctx.bot.send_message(chat_id=update.effective_chat.id, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_html(update: Update, ctx: ContextTypes.DEFAULT_TYPE, text: str):
|
||||||
|
await ctx.bot.send_message(
|
||||||
|
chat_id=update.effective_chat.id,
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# General / all-auth commands (12 total)
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
user = update.effective_user
|
||||||
|
name = escape(user.first_name or "friend")
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"👋 Hey <b>{name}</b>! I'm <b>Puppy</b> (پاپی) — your AI companion.\n\n"
|
||||||
|
"• Chat with me directly in private\n"
|
||||||
|
"• In groups: say my name (<code>Puppy</code> / <code>پاپی</code>), @mention me, or reply to me\n"
|
||||||
|
"• Send photos and I'll analyze them\n"
|
||||||
|
"Use /help to see all commands. Let's go! 🐾"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_ping(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
await update.message.reply_text("🟢 Pong! Puppy is alive and wagging. 🐾")
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_clear(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
context.clear_history(update.effective_chat.id)
|
||||||
|
await update.message.reply_text("🧹 Your conversation history has been cleared.")
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_myhistory(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
summary = context.history_summary(chat_id, n=8)
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"<b>📜 Your recent conversation:</b>\n\n<pre>{escape(summary)}</pre>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_setmaxtoken(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args:
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"Usage: <code>/setmaxtoken <{MAX_TOKENS_MIN}–{MAX_TOKENS_MAX}></code>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
n = int(ctx.args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("❌ Please provide a number.")
|
||||||
|
return
|
||||||
|
|
||||||
|
n = max(MAX_TOKENS_MIN, min(MAX_TOKENS_MAX, n))
|
||||||
|
context.set_user_setting(update.effective_user.id, "max_tokens", n)
|
||||||
|
await update.message.reply_html(f"✅ Max reply tokens set to <code>{n}</code>.")
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_getmaxtoken(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
settings = context.get_user_settings(update.effective_user.id)
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"Max tokens: <code>{settings['max_tokens']}</code> (default: {MAX_TOKENS_DEFAULT})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_resettoken(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
context.set_user_setting(update.effective_user.id, "max_tokens", MAX_TOKENS_DEFAULT)
|
||||||
|
await update.message.reply_html(f"✅ Max tokens reset to <code>{MAX_TOKENS_DEFAULT}</code>.")
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_setlang(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args or ctx.args[0] not in ("auto", "fa", "en"):
|
||||||
|
await update.message.reply_html(
|
||||||
|
"Usage: <code>/setlang <auto|fa|en></code>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
lang = ctx.args[0]
|
||||||
|
context.set_user_setting(update.effective_user.id, "lang", lang)
|
||||||
|
label = {"auto": "Auto-detect", "fa": "Persian (فارسی)", "en": "English"}[lang]
|
||||||
|
await update.message.reply_html(f"✅ Language set to <b>{label}</b>.")
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
settings = context.get_user_settings(user_id)
|
||||||
|
search_icon = "✅" if settings["search_enabled"] else "❌"
|
||||||
|
lang_map = {"auto": "Auto-detect", "fa": "Persian", "en": "English"}
|
||||||
|
history_len = len(context.get_history(update.effective_chat.id))
|
||||||
|
|
||||||
|
provider = ai_client.provider_status()
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"<b>⚙️ Your settings</b>\n\n"
|
||||||
|
f"Max tokens: <code>{settings['max_tokens']}</code>\n"
|
||||||
|
f"Web search: {search_icon}\n"
|
||||||
|
f"Language: <code>{lang_map.get(settings['lang'], settings['lang'])}</code>\n"
|
||||||
|
f"History messages: <code>{history_len}</code>\n"
|
||||||
|
f"AI provider: <code>{provider['provider']}</code>\n"
|
||||||
|
f"AI model: <code>{provider['model']}</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_togglesearch(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
settings = context.get_user_settings(user_id)
|
||||||
|
new_val = not settings["search_enabled"]
|
||||||
|
context.set_user_setting(user_id, "search_enabled", new_val)
|
||||||
|
icon = "✅ enabled" if new_val else "❌ disabled"
|
||||||
|
await update.message.reply_text(f"Web search is now {icon}.")
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_search(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args:
|
||||||
|
await update.message.reply_text("Usage: /search <query>")
|
||||||
|
return
|
||||||
|
|
||||||
|
query = " ".join(ctx.args)
|
||||||
|
await ctx.bot.send_chat_action(update.effective_chat.id, action="typing")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await ai_client.search_web(query)
|
||||||
|
header = f"🔍 <b>Results for:</b> {escape(query)}\n\n"
|
||||||
|
for chunk in chunker.chunk_text(header + result):
|
||||||
|
await update.message.reply_html(chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
context.record_error(f"Search failed: {exc}")
|
||||||
|
await update.message.reply_text(f"❌ Search failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_ocr(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
target = update.message.reply_to_message if update.message else None
|
||||||
|
if not target:
|
||||||
|
await update.message.reply_text("Reply to a photo or image file with /ocr.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if target.photo:
|
||||||
|
file_id = target.photo[-1].file_id
|
||||||
|
mime_type = "image/jpeg"
|
||||||
|
elif target.document and (target.document.mime_type or "").startswith("image/"):
|
||||||
|
file_id = target.document.file_id
|
||||||
|
mime_type = target.document.mime_type or "image/jpeg"
|
||||||
|
else:
|
||||||
|
await update.message.reply_text("Reply to a photo or image file with /ocr.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await ctx.bot.send_chat_action(update.effective_chat.id, ChatAction.TYPING)
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_file = await ctx.bot.get_file(file_id)
|
||||||
|
image_bytes = bytes(await image_file.download_as_bytearray())
|
||||||
|
settings = context.get_user_settings(update.effective_user.id)
|
||||||
|
caption = " ".join(ctx.args).strip() or target.caption or ""
|
||||||
|
sys_prompt = context.get_system_prompt()
|
||||||
|
context.reset_chat_if_input_too_large(update.effective_chat.id, sys_prompt, keep_last=False)
|
||||||
|
reply = await ai_client.chat_with_image(
|
||||||
|
messages=context.get_history(update.effective_chat.id),
|
||||||
|
system_prompt=sys_prompt,
|
||||||
|
max_tokens=settings["max_tokens"],
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
mime_type=mime_type,
|
||||||
|
caption=caption,
|
||||||
|
analysis_mode="ocr",
|
||||||
|
)
|
||||||
|
|
||||||
|
context.add_message(update.effective_chat.id, "user", "[OCR image]" + (f": {caption}" if caption else ""))
|
||||||
|
context.add_message(update.effective_chat.id, "assistant", reply)
|
||||||
|
context.increment_msg()
|
||||||
|
context.record_event("ocr_requests")
|
||||||
|
|
||||||
|
formatted, mode = formatter.safe_send(reply)
|
||||||
|
for part in chunker.chunk_text(formatted):
|
||||||
|
await update.message.reply_text(part, parse_mode=mode)
|
||||||
|
except Exception as exc:
|
||||||
|
context.record_error(f"OCR failed: {exc}")
|
||||||
|
await update.message.reply_text(f"❌ OCR failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# Owner-only commands (15 total)
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_stats(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
stats = context.get_stats()
|
||||||
|
events = stats["events"]
|
||||||
|
provider = ai_client.provider_status()
|
||||||
|
known = len(auth.get_known_users())
|
||||||
|
authed = len(auth.get_authorized())
|
||||||
|
banned = len(auth.get_banned())
|
||||||
|
public = "ON" if auth.is_public() else "OFF"
|
||||||
|
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"<b>📊 Admin Dashboard</b>\n\n"
|
||||||
|
f"Uptime: <code>{stats['uptime']}</code>\n"
|
||||||
|
f"Messages processed: <code>{stats['messages_processed']}</code>\n"
|
||||||
|
f"Active chats: <code>{stats['active_chats']}</code>\n"
|
||||||
|
f"Stored history messages: <code>{stats['stored_history_messages']}</code>\n"
|
||||||
|
f"Known users: <code>{known}</code>\n"
|
||||||
|
f"Authorized users: <code>{authed}</code>\n"
|
||||||
|
f"Banned users: <code>{banned}</code>\n"
|
||||||
|
f"Public mode: <code>{public}</code>\n\n"
|
||||||
|
f"<b>Usage</b>\n"
|
||||||
|
f"Text chats: <code>{events.get('text_chats', 0)}</code>\n"
|
||||||
|
f"Images analyzed: <code>{events.get('images_analyzed', 0)}</code>\n"
|
||||||
|
f"Images generated: <code>{events.get('images_generated', 0)}</code>\n"
|
||||||
|
f"Web searches: <code>{events.get('web_searches', 0)}</code>\n"
|
||||||
|
f"Image searches: <code>{events.get('image_searches', 0)}</code>\n"
|
||||||
|
f"OCR requests: <code>{events.get('ocr_requests', 0)}</code>\n"
|
||||||
|
f"Multi-image analyses: <code>{events.get('multi_image_analyses', 0)}</code>\n"
|
||||||
|
f"Provider fallbacks: <code>{events.get('provider_fallbacks', 0)}</code>\n"
|
||||||
|
f"History auto-resets: <code>{events.get('history_auto_resets', 0)}</code>\n"
|
||||||
|
f"Model-change resets: <code>{events.get('history_resets_on_model_change', 0)}</code>\n"
|
||||||
|
f"Errors: <code>{events.get('errors', 0)}</code>\n\n"
|
||||||
|
f"<b>AI</b>\n"
|
||||||
|
f"Provider: <code>{provider['provider']}</code>\n"
|
||||||
|
f"Model: <code>{provider['model']}</code>\n"
|
||||||
|
f"Google key: {'✅' if provider['google_configured'] else '❌'}\n"
|
||||||
|
f"OpenRouter key: {'✅' if provider['openrouter_configured'] else '❌'}\n"
|
||||||
|
f"Vision: <code>{provider['vision_provider']}</code>\n"
|
||||||
|
f"Last fallback: <code>{escape(provider['last_fallback'])}</code>\n"
|
||||||
|
f"Last error: <code>{escape(stats['last_error'] or 'none')}</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_uptime(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
stats = context.get_stats()
|
||||||
|
await update.message.reply_html(f"⏱️ Uptime: <code>{stats['uptime']}</code>")
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_version(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
provider = ai_client.provider_status()
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"🐾 <b>Puppy Bot</b> v<code>{BOT_VERSION}</code>\n"
|
||||||
|
f"Provider: <code>{provider['provider']}</code>\n"
|
||||||
|
f"Model: <code>{provider['model']}</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_addauth(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args:
|
||||||
|
await update.message.reply_text("Usage: /addauth <user_id>")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
uid = int(ctx.args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("❌ Invalid user ID.")
|
||||||
|
return
|
||||||
|
auth.add_user(uid)
|
||||||
|
await update.message.reply_html(f"✅ User <code>{uid}</code> is now authorized.")
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_removeauth(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args:
|
||||||
|
await update.message.reply_text("Usage: /removeauth <user_id>")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
uid = int(ctx.args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("❌ Invalid user ID.")
|
||||||
|
return
|
||||||
|
auth.remove_user(uid)
|
||||||
|
await update.message.reply_html(f"✅ User <code>{uid}</code> removed from authorized list.")
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_listauth(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
users = auth.get_authorized()
|
||||||
|
if not users:
|
||||||
|
await update.message.reply_text("No authorized users.")
|
||||||
|
return
|
||||||
|
lines = "\n".join(f"• <code>{uid}</code>" for uid in users)
|
||||||
|
await update.message.reply_html(f"<b>Authorized users ({len(users)}):</b>\n{lines}")
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_ban(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args:
|
||||||
|
await update.message.reply_text("Usage: /ban <user_id>")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
uid = int(ctx.args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("❌ Invalid user ID.")
|
||||||
|
return
|
||||||
|
auth.ban_user(uid)
|
||||||
|
await update.message.reply_html(f"🚫 User <code>{uid}</code> has been banned.")
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_unban(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args:
|
||||||
|
await update.message.reply_text("Usage: /unban <user_id>")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
uid = int(ctx.args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("❌ Invalid user ID.")
|
||||||
|
return
|
||||||
|
auth.unban_user(uid)
|
||||||
|
await update.message.reply_html(f"✅ User <code>{uid}</code> has been unbanned.")
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_listban(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
users = auth.get_banned()
|
||||||
|
if not users:
|
||||||
|
await update.message.reply_text("No banned users.")
|
||||||
|
return
|
||||||
|
lines = "\n".join(f"• <code>{uid}</code>" for uid in users)
|
||||||
|
await update.message.reply_html(f"<b>Banned users ({len(users)}):</b>\n{lines}")
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_userinfo(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args:
|
||||||
|
await update.message.reply_text("Usage: /userinfo <user_id>")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
uid = int(ctx.args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("❌ Invalid user ID.")
|
||||||
|
return
|
||||||
|
|
||||||
|
is_auth = auth.is_authorized(uid)
|
||||||
|
is_ban = uid in auth.get_banned()
|
||||||
|
is_known = uid in auth.get_known_users()
|
||||||
|
settings = context.get_user_settings(uid)
|
||||||
|
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"<b>👤 User info for <code>{uid}</code></b>\n\n"
|
||||||
|
f"Authorized: {'✅' if is_auth else '❌'}\n"
|
||||||
|
f"Banned: {'🚫' if is_ban else '—'}\n"
|
||||||
|
f"Known (interacted): {'✅' if is_known else '—'}\n"
|
||||||
|
f"Max tokens: <code>{settings['max_tokens']}</code>\n"
|
||||||
|
f"Web search: {'✅' if settings['search_enabled'] else '❌'}\n"
|
||||||
|
f"Language: <code>{settings['lang']}</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_broadcast(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args:
|
||||||
|
await update.message.reply_text("Usage: /broadcast <message>")
|
||||||
|
return
|
||||||
|
|
||||||
|
text = " ".join(ctx.args)
|
||||||
|
known = auth.get_known_users()
|
||||||
|
if not known:
|
||||||
|
await update.message.reply_text("No known users to broadcast to.")
|
||||||
|
return
|
||||||
|
|
||||||
|
sent = failed = 0
|
||||||
|
for uid in known:
|
||||||
|
try:
|
||||||
|
await ctx.bot.send_message(
|
||||||
|
uid,
|
||||||
|
f"📢 <b>Broadcast from Puppy:</b>\n\n{escape(text)}",
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
sent += 1
|
||||||
|
except Exception:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"✅ Broadcast sent: <code>{sent}</code> delivered, <code>{failed}</code> failed."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_clearall(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
context.clear_all_histories()
|
||||||
|
await update.message.reply_text("🧹 All conversation histories cleared.")
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_setpersonality(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
text = update.message.text or ""
|
||||||
|
# Strip the /setpersonality command prefix
|
||||||
|
prompt = text.partition(" ")[2].strip()
|
||||||
|
if not prompt:
|
||||||
|
current = context.get_system_prompt()
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"Usage: <code>/setpersonality <new system prompt></code>\n\n"
|
||||||
|
f"<b>Current prompt:</b>\n<pre>{escape(current[:800])}</pre>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
context.set_system_prompt(prompt)
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"✅ System prompt updated.\n\n<pre>{escape(prompt[:300])}</pre>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_resetpersonality(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
context.reset_system_prompt()
|
||||||
|
await update.message.reply_text("✅ Personality reset to default.")
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_setmodel(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args:
|
||||||
|
provider = ai_client.provider_status()
|
||||||
|
await _send_html(
|
||||||
|
update,
|
||||||
|
ctx,
|
||||||
|
f"Provider: <code>{provider['provider']}</code>\n"
|
||||||
|
f"Model: <code>{provider['model']}</code>\n\n"
|
||||||
|
"Usage: <code>/setmodel [google|openrouter] <model_name></code>\n"
|
||||||
|
"Examples:\n"
|
||||||
|
"<code>/setmodel google gemini-2.5-flash-lite</code>\n"
|
||||||
|
"<code>/setmodel openrouter openai/gpt-4o-mini</code>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
provider = ai_client.get_provider()
|
||||||
|
model_args = ctx.args
|
||||||
|
explicit_provider = False
|
||||||
|
if ctx.args[0].lower() in ai_client.get_available_providers():
|
||||||
|
provider = ctx.args[0].lower()
|
||||||
|
model_args = ctx.args[1:]
|
||||||
|
explicit_provider = True
|
||||||
|
if not model_args:
|
||||||
|
await _send_text(update, ctx, "Usage: /setmodel [google|openrouter] <model_name>")
|
||||||
|
return
|
||||||
|
|
||||||
|
model = model_args[0]
|
||||||
|
if not explicit_provider and provider == "google" and "/" in model:
|
||||||
|
provider = "openrouter"
|
||||||
|
explicit_provider = True
|
||||||
|
|
||||||
|
ok, note = await ai_client.validate_model(model, provider=provider)
|
||||||
|
if not ok:
|
||||||
|
await _send_html(update, ctx, f"❌ {escape(note)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
ai_client.set_model(model, provider=provider)
|
||||||
|
if provider != ai_client.get_provider():
|
||||||
|
ai_client.set_provider(provider)
|
||||||
|
context.clear_all_histories()
|
||||||
|
context.record_event("history_resets_on_model_change")
|
||||||
|
|
||||||
|
await _send_html(
|
||||||
|
update,
|
||||||
|
ctx,
|
||||||
|
f"✅ Model switched to <code>{model}</code> "
|
||||||
|
f"(provider: <code>{provider}</code>).\n"
|
||||||
|
"🧹 Conversation memory reset for the new model."
|
||||||
|
+ (f"\n\nℹ️ {escape(note)}" if note else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_models(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""List available models. Usage: /models [google|openrouter] [free] [filter]"""
|
||||||
|
args = ctx.args or []
|
||||||
|
provider = ai_client.get_provider()
|
||||||
|
if args and args[0].lower() in ai_client.get_available_providers():
|
||||||
|
provider = args[0].lower()
|
||||||
|
args = args[1:]
|
||||||
|
|
||||||
|
if provider == "google":
|
||||||
|
lines = [
|
||||||
|
f"• <code>{escape(model)}</code>" + (" ✅" if model == ai_client.get_model_for_provider("google") else "")
|
||||||
|
for model in ai_client.GOOGLE_TEXT_MODELS
|
||||||
|
]
|
||||||
|
await update.message.reply_html(
|
||||||
|
"<b>Google Models</b>\n\n"
|
||||||
|
+ "\n".join(lines)
|
||||||
|
+ "\n\n<i>Use /setmodel google <model_id> to switch.</i>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
free_only = "free" in [a.lower() for a in args]
|
||||||
|
search_term = " ".join(a for a in args if a.lower() != "free").strip().lower()
|
||||||
|
|
||||||
|
status = await update.message.reply_text("🔍 Fetching models…")
|
||||||
|
models = await ai_client.fetch_or_models(free_only=free_only)
|
||||||
|
|
||||||
|
if not models:
|
||||||
|
await status.edit_text("❌ Could not fetch model list from OpenRouter.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if search_term:
|
||||||
|
models = [m for m in models if search_term in m.get("id", "").lower()
|
||||||
|
or search_term in m.get("name", "").lower()]
|
||||||
|
|
||||||
|
if not models:
|
||||||
|
await status.edit_text(f"No models found matching <code>{escape(search_term)}</code>.", parse_mode=ParseMode.HTML)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cap at 30 results
|
||||||
|
total = len(models)
|
||||||
|
models = models[:30]
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for m in models:
|
||||||
|
mid = m.get("id", "")
|
||||||
|
ctx_k = m.get("context_length", 0)
|
||||||
|
ctx_str = f"{ctx_k // 1000}k" if ctx_k else "?"
|
||||||
|
p_prompt = m.get("pricing", {}).get("prompt", "?")
|
||||||
|
free_tag = " 🆓" if str(p_prompt) == "0" else ""
|
||||||
|
lines.append(f"• <code>{escape(mid)}</code> [{ctx_str}]{free_tag}")
|
||||||
|
|
||||||
|
header = (
|
||||||
|
f"<b>OpenRouter Models</b> ({total} total"
|
||||||
|
+ (f", showing {len(models)}" if total > len(models) else "")
|
||||||
|
+ (", free only" if free_only else "")
|
||||||
|
+ (f", filter: {escape(search_term)}" if search_term else "")
|
||||||
|
+ ")\n\n"
|
||||||
|
)
|
||||||
|
footer = "\n\n<i>Use /setmodel openrouter <model_id> to switch.\nTip: /models openrouter free — show only free models\n/models openrouter <keyword> — filter by name</i>"
|
||||||
|
body = "\n".join(lines)
|
||||||
|
|
||||||
|
from utils import chunker
|
||||||
|
full_text = header + body + footer
|
||||||
|
await status.delete()
|
||||||
|
for chunk in chunker.chunk_text(full_text, max_len=3800):
|
||||||
|
await update.message.reply_html(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_setprovider(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not ctx.args or ctx.args[0].lower() not in ("google", "openrouter"):
|
||||||
|
provider = ai_client.provider_status()
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"Current provider: <code>{provider['provider']}</code>\n"
|
||||||
|
f"Current model: <code>{provider['model']}</code>\n"
|
||||||
|
f"Google model: <code>{provider['google_model']}</code>\n"
|
||||||
|
f"OpenRouter model: <code>{provider['openrouter_model']}</code>\n\n"
|
||||||
|
"Usage: <code>/setprovider <google|openrouter> [model]</code>\n\n"
|
||||||
|
"<b>google</b> — Gemini models via Google AI Studio\n"
|
||||||
|
"<b>openrouter</b> — 300+ models (GPT-4o, Claude, Llama, etc.) via OpenRouter\n\n"
|
||||||
|
"<i>Note: image generation and vision always use Google regardless of provider.</i>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
provider = ctx.args[0].lower()
|
||||||
|
try:
|
||||||
|
if len(ctx.args) > 1:
|
||||||
|
model = ctx.args[1]
|
||||||
|
ok, note = await ai_client.validate_model(model, provider=provider)
|
||||||
|
if not ok:
|
||||||
|
await update.message.reply_html(f"❌ {escape(note)}")
|
||||||
|
return
|
||||||
|
ai_client.set_model(model, provider=provider)
|
||||||
|
else:
|
||||||
|
note = ""
|
||||||
|
ai_client.set_provider(provider)
|
||||||
|
context.clear_all_histories()
|
||||||
|
context.record_event("history_resets_on_model_change")
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"✅ Provider switched to <b>{provider}</b>.\n"
|
||||||
|
f"Active model: <code>{ai_client.get_model()}</code>\n\n"
|
||||||
|
f"Use /setmodel to change the model for this provider.\n"
|
||||||
|
"🧹 Conversation memory reset for the new provider."
|
||||||
|
+ (f"\n\nℹ️ {escape(note)}" if note else "")
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
await update.message.reply_text(f"❌ {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_model(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
provider = ai_client.provider_status()
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"<b>AI Provider Status</b>\n\n"
|
||||||
|
f"Active provider: <code>{provider['provider']}</code>\n"
|
||||||
|
f"Active model: <code>{provider['model']}</code>\n"
|
||||||
|
f"Google model: <code>{provider['google_model']}</code>\n"
|
||||||
|
f"OpenRouter model: <code>{provider['openrouter_model']}</code>\n"
|
||||||
|
f"Vision provider: <code>{provider['vision_provider']}</code>\n"
|
||||||
|
f"Image generation provider: <code>{provider['image_provider']}</code>\n\n"
|
||||||
|
f"Last fallback: <code>{escape(provider['last_fallback'])}</code>\n\n"
|
||||||
|
"Commands:\n"
|
||||||
|
"<code>/setprovider google|openrouter [model]</code>\n"
|
||||||
|
"<code>/setmodel [google|openrouter] <model></code>\n"
|
||||||
|
"<code>/models [google|openrouter] [free] [keyword]</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_reload(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
auth.load()
|
||||||
|
await update.message.reply_text("🔄 authorized_users.json reloaded from disk.")
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# Image generation (all authorized users)
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@_auth_required
|
||||||
|
async def cmd_imagine(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
if not ctx.args:
|
||||||
|
used = context.images_used_today(user_id)
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"Usage: <code>/imagine <description></code>\n"
|
||||||
|
f"Images used today: <code>{used}/{IMAGE_DAILY_LIMIT}</code>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not context.can_generate_image(user_id):
|
||||||
|
used = context.images_used_today(user_id)
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"Daily limit reached (<code>{used}/{IMAGE_DAILY_LIMIT}</code>). "
|
||||||
|
"Try again in 24 hours."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt = " ".join(ctx.args)
|
||||||
|
status = await update.message.reply_text("🎨 Generating…")
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_bytes = await ai_client.generate_image(prompt)
|
||||||
|
context.record_image(user_id)
|
||||||
|
await status.delete()
|
||||||
|
await update.message.reply_photo(
|
||||||
|
photo=image_bytes,
|
||||||
|
caption=f"🎨 {prompt[:900]}",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
context.record_error(f"Image generation failed: {exc}")
|
||||||
|
await status.edit_text(f"❌ Image generation failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# Utility: /myid (anyone — useful for first-time setup)
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def cmd_myid(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
user = update.effective_user
|
||||||
|
chat = update.effective_chat
|
||||||
|
await update.message.reply_html(
|
||||||
|
f"<b>Your Telegram ID:</b> <code>{user.id}</code>\n"
|
||||||
|
f"<b>Username:</b> @{escape(user.username or 'none')}\n"
|
||||||
|
f"<b>Chat ID:</b> <code>{chat.id}</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# Public mode unlock / lock — owner only
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_unlock(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if auth.is_public():
|
||||||
|
await update.message.reply_html(
|
||||||
|
"ℹ️ Bot is already <b>public</b> — everyone can use it.\n"
|
||||||
|
"Use /lock to restrict back to authorized users only."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
auth.set_public(True)
|
||||||
|
await update.message.reply_html(
|
||||||
|
"🔓 <b>Public mode ON</b>\n\n"
|
||||||
|
"Anyone can now chat with Puppy.\n"
|
||||||
|
"• Existing per-user settings are preserved\n"
|
||||||
|
"• Banned users are still blocked\n"
|
||||||
|
"Use /lock to re-restrict."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_required
|
||||||
|
async def cmd_lock(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not auth.is_public():
|
||||||
|
await update.message.reply_html(
|
||||||
|
"ℹ️ Bot is already <b>locked</b> — only authorized users can use it."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
auth.set_public(False)
|
||||||
|
await update.message.reply_html(
|
||||||
|
"🔒 <b>Public mode OFF</b>\n\n"
|
||||||
|
"Only authorized users can chat with Puppy again.\n"
|
||||||
|
"Use /unlock to open to everyone."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user