Files
ai-tg/handlers/admin.py
T
2026-09-18 22:41:17 +03:30

738 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 &lt;{MAX_TOKENS_MIN}–{MAX_TOKENS_MAX}&gt;</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 &lt;auto|fa|en&gt;</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 &lt;new system prompt&gt;</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] &lt;model_name&gt;</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 &lt;model_id&gt; 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 &lt;model_id&gt; to switch.\nTip: /models openrouter free — show only free models\n/models openrouter &lt;keyword&gt; — 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 &lt;google|openrouter&gt; [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] &lt;model&gt;</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 &lt;description&gt;</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."
)