Files
multi-bot-tg/app/commands/builtins.py
Azimkin a84467c385 feat: add Claude and OpenRouter AI providers
OpenRouter is used when no provider flag is set. Claude is selected with .cl, and GLM stays available via .glm.
2026-10-13 08:56:16 +00:00

490 lines
15 KiB
Python

from __future__ import annotations
import logging
import os
import signal
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
from pyrogram.enums import MessageEntityType
from pyrogram.types import MessageEntity
from app.agentic import parse_agent_invocation, run_agent_turn
from app.commands.core import CommandRegistry, normalize_command_name
from app.transcription import transcribe_file
if TYPE_CHECKING:
from app.commands.core import CommandContext
command_registry = CommandRegistry()
DEFAULT_REGISTRY = command_registry
log = logging.getLogger(__name__)
def _utf16_length(value: str) -> int:
return len(value.encode("utf-16-le")) // 2
def _split_transcript(value: str, *, limit: int = 4000) -> list[str]:
chunks: list[str] = []
remaining = value.strip()
while remaining:
units = 0
cut = len(remaining)
for index, character in enumerate(remaining):
units += _utf16_length(character)
if units > limit:
cut = index
break
if cut == len(remaining):
chunks.append(remaining)
break
whitespace = max(remaining.rfind("\n", 0, cut), remaining.rfind(" ", 0, cut))
if whitespace > cut // 2:
cut = whitespace
chunk = remaining[:cut].strip()
if chunk:
chunks.append(chunk)
remaining = remaining[cut:].strip()
return chunks
async def _set_reaction(message: Any, emoji: str | None) -> None:
try:
await message.react(emoji)
except Exception:
log.exception(
"Could not set reaction %r on message %s",
emoji,
getattr(message, "id", None),
)
async def _dislike(message: Any) -> None:
await _set_reaction(message, "👎")
def _transcribable_media(message: Any) -> Any | None:
for field in ("voice", "video_note", "video"):
media = getattr(message, field, None)
if media is not None:
return media
return None
def _user_label(user: Any | None) -> str:
if user is None:
return "unknown"
parts = [getattr(user, "first_name", None), getattr(user, "last_name", None)]
full_name = " ".join(part for part in parts if part)
username = getattr(user, "username", None)
if full_name and username:
return f"{full_name} (@{username})"
if full_name:
return full_name
if username:
return f"@{username}"
return str(getattr(user, "id", "unknown"))
def _display_command(command: str) -> str:
return f"..{command}"
def _value_or_none(value: Any) -> str:
if value is None or value == "":
return "none"
return str(value)
def _message_text_preview(message: Any, *, limit: int = 180) -> str | None:
text = getattr(message, "text", None) or getattr(message, "caption", None)
if not text:
return None
text = str(text).replace("\n", " ").strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + "..."
def _topic_id(message: Any) -> Any | None:
topic = getattr(message, "topic", None)
topic_id = getattr(topic, "id", None)
if topic_id is not None:
return topic_id
for field in (
"message_thread_id",
"direct_messages_topic_id",
"reply_to_top_message_id",
):
value = getattr(message, field, None)
if value is not None:
return value
created_topic = getattr(message, "forum_topic_created", None)
return getattr(created_topic, "id", None)
def _topic_title(message: Any) -> str | None:
topic = getattr(message, "topic", None)
title = getattr(topic, "title", None)
if title:
return str(title)
created_topic = getattr(message, "forum_topic_created", None)
title = getattr(created_topic, "title", None)
return str(title) if title else None
def _append_chat_lines(lines: list[str], chat: Any, *, prefix: str = "chat") -> None:
lines.append(f"{prefix}_id: {_value_or_none(getattr(chat, 'id', None))}")
chat_type = getattr(chat, "type", None)
if chat_type is not None:
value = getattr(chat_type, "value", chat_type)
lines.append(f"{prefix}_type: {_value_or_none(value)}")
title = getattr(chat, "title", None)
if title:
lines.append(f"{prefix}_title: {title}")
username = getattr(chat, "username", None)
if username:
lines.append(f"{prefix}_username: @{username}")
def _append_user_lines(lines: list[str], user: Any | None, *, prefix: str = "user") -> None:
lines.append(f"{prefix}_id: {_value_or_none(getattr(user, 'id', None))}")
if user is None:
return
username = getattr(user, "username", None)
if username:
lines.append(f"{prefix}_username: @{username}")
lines.append(f"{prefix}_name: {_user_label(user)}")
def _append_sender_chat_lines(lines: list[str], chat: Any | None, *, prefix: str = "sender_chat") -> None:
if chat is None:
return
lines.append(f"{prefix}_id: {_value_or_none(getattr(chat, 'id', None))}")
title = getattr(chat, "title", None)
if title:
lines.append(f"{prefix}_title: {title}")
username = getattr(chat, "username", None)
if username:
lines.append(f"{prefix}_username: @{username}")
def _format_id_report(ctx: "CommandContext") -> str:
message = ctx.message
lines: list[str] = ["Current message:"]
_append_chat_lines(lines, getattr(message, "chat", None))
lines.append(f"message_id: {_value_or_none(getattr(message, 'id', None))}")
lines.append(f"thread_id: {_value_or_none(_topic_id(message))}")
topic_title = _topic_title(message)
if topic_title:
lines.append(f"thread_title: {topic_title}")
_append_user_lines(lines, getattr(message, "from_user", None), prefix="user")
_append_sender_chat_lines(lines, getattr(message, "sender_chat", None))
reply = getattr(message, "reply_to_message", None)
if reply is not None:
lines.extend(["", "Replied message:"])
lines.append(f"reply_message_id: {_value_or_none(getattr(reply, 'id', None))}")
lines.append(f"reply_thread_id: {_value_or_none(_topic_id(reply))}")
reply_topic_title = _topic_title(reply)
if reply_topic_title:
lines.append(f"reply_thread_title: {reply_topic_title}")
_append_user_lines(lines, getattr(reply, "from_user", None), prefix="reply_user")
_append_sender_chat_lines(lines, getattr(reply, "sender_chat", None), prefix="reply_sender_chat")
media = getattr(reply, "media", None)
if media is not None:
lines.append(f"reply_media: {_value_or_none(getattr(media, 'value', media))}")
preview = _message_text_preview(reply)
if preview:
lines.append(f"reply_text: {preview}")
return "\n".join(lines)
def _format_permissions(ctx: "CommandContext") -> str:
store = ctx.acl_store
target = ctx.replied_user
if store is None:
return "Permission store is not initialized"
if target is None:
return "Reply to a user's message first"
allowed = store.list_allowed(target.id)
denied = store.list_denied(target.id)
lines = [f"Permissions for {_user_label(target)} ({target.id}):"]
lines.append(
"allowed: "
+ (", ".join(_display_command(command) for command in sorted(allowed)) if allowed else "none")
)
lines.append(
"denied: "
+ (", ".join(_display_command(command) for command in sorted(denied)) if denied else "none")
)
return "\n".join(lines)
def _resolve_target_commands(ctx: "CommandContext") -> tuple[list[str], list[str]]:
commands: list[str] = []
errors: list[str] = []
seen: set[str] = set()
for token in ctx.argv:
for part in (piece.strip() for piece in token.split(",")):
normalized = normalize_command_name(part)
if not normalized:
continue
definition = ctx.manager.command_registry.resolve(normalized)
if definition is None:
errors.append(f"Unknown command: {part}")
continue
if not definition.is_shared:
errors.append(f"{definition.display_name} is owner-only and cannot be granted")
continue
if definition.name in seen:
continue
seen.add(definition.name)
commands.append(definition.name)
return commands, errors
async def _change_permissions(ctx: "CommandContext", *, allow: bool) -> None:
target = ctx.replied_user
if target is None:
await ctx.reply("Reply to the user's message first")
return
owner_id = ctx.owner_id
if owner_id is not None and target.id == owner_id:
await ctx.reply("Owner access cannot be changed")
return
commands, errors = _resolve_target_commands(ctx)
if not commands and not errors:
await ctx.reply("Provide at least one command name")
return
store = ctx.acl_store
if store is None:
await ctx.reply("Permission store is not initialized")
return
changed: list[str] = []
unchanged: list[str] = []
for command in commands:
did_change = store.allow(target.id, command) if allow else store.deny(target.id, command)
if did_change:
changed.append(command)
else:
unchanged.append(command)
lines = [
f"Target: {_user_label(target)} ({target.id})",
f"Action: {'allow' if allow else 'deny'}",
]
if changed:
lines.append("Changed: " + ", ".join(_display_command(command) for command in sorted(changed)))
if unchanged:
lines.append(
"Already set: " + ", ".join(_display_command(command) for command in sorted(unchanged))
)
if errors:
lines.append("Errors:")
lines.extend(f"- {error}" for error in errors)
await ctx.reply("\n".join(lines))
@command_registry.command(
"ping",
"Check that the account is alive",
usage=".ping",
category="system",
)
async def ping(ctx: "CommandContext") -> None:
me = ctx.account.me
await ctx.reply(
"\n".join(
[
"pong",
f"account: {ctx.account.config.name}",
f"session: {ctx.account.config.session}",
f"user_id: {getattr(me, 'id', 'unknown')}",
]
)
)
@command_registry.command(
"status",
"Show account and ACL state",
usage=".status",
category="system",
)
async def status(ctx: "CommandContext") -> None:
lines = [ctx.manager.build_status_report()]
store = ctx.acl_store
if store is not None:
stats = store.stats()
lines.append(
"ACL stats: "
f"allowed_users={stats['allowed_users']}, "
f"denied_users={stats['denied_users']}, "
f"allow_rules={stats['allow_rules']}, "
f"deny_rules={stats['deny_rules']}"
)
await ctx.reply("\n".join(lines))
@command_registry.command(
"restart",
"Stop the bot process so its supervisor can restart it",
usage=".restart",
category="system",
)
async def restart(ctx: "CommandContext") -> None:
await ctx.reply("Stopping bot process for restart")
os.kill(os.getpid(), signal.SIGTERM)
@command_registry.command(
"id",
"Show chat, thread, user, and replied message IDs",
usage=".id",
category="system",
)
async def id_command(ctx: "CommandContext") -> None:
await ctx.reply(_format_id_report(ctx))
@command_registry.command(
"help",
"Show command catalog",
usage=".help",
category="system",
)
async def help_command(ctx: "CommandContext") -> None:
await ctx.reply(ctx.manager.command_registry.render_help(ctx))
@command_registry.command(
"rights",
"Show ACL state for the replied user",
usage=".rights",
category="access",
).alias("acl").require_reply()
async def rights(ctx: "CommandContext") -> None:
await ctx.reply(_format_permissions(ctx))
@command_registry.command(
"allow",
"Grant a command to the replied user",
usage=".allow <command> [more...]",
category="access",
).require_reply().require_args()
async def allow(ctx: "CommandContext") -> None:
await _change_permissions(ctx, allow=True)
@command_registry.command(
"deny",
"Revoke a command from the replied user",
usage=".deny <command> [more...]",
category="access",
).require_reply().require_args()
async def deny(ctx: "CommandContext") -> None:
await _change_permissions(ctx, allow=False)
@command_registry.command(
"echo",
"Echo text for granted users",
usage=".echo / ..echo <text>",
category="shared",
).shared()
async def echo(ctx: "CommandContext") -> None:
text = ctx.args.strip()
if not text:
await ctx.reply("Usage: .echo / ..echo <text>")
return
await ctx.reply(text)
@command_registry.command(
"ai",
"Run an agent with optional Telegram history and media",
usage=".ai / ..ai [messages] [.glm|.ds|.cl|.or] [.th] <prompt>",
category="shared",
).shared()
async def ai(ctx: "CommandContext") -> None:
await _set_reaction(ctx.message, "🤔")
failed = False
try:
invocation = parse_agent_invocation(ctx.args, ctx.manager.config.ai)
turn = await run_agent_turn(
ctx.client,
ctx.message,
ctx.account,
ctx.account.ai_sessions,
invocation,
ctx.manager.config.ai,
)
for chunk in _split_transcript(turn.answer):
sent = await ctx.reply(chunk)
ctx.account.ai_sessions.register_response(turn.handle, sent)
except Exception:
log.exception("AI command failed for message %s", getattr(ctx.message, "id", None))
failed = True
if failed:
await _dislike(ctx.message)
else:
await _set_reaction(ctx.message, None)
@command_registry.command(
"txt",
"Transcribe a replied voice message, video note, or video",
usage=".txt / ..txt (reply to media)",
category="shared",
).shared()
async def txt(ctx: "CommandContext") -> None:
reply = ctx.replied_message
media = _transcribable_media(reply) if reply is not None else None
if ctx.args.strip() or reply is None or media is None:
await _dislike(ctx.message)
return
try:
with tempfile.TemporaryDirectory(prefix="multi-bot-txt-") as temp_dir:
downloaded = await ctx.client.download_media(
reply,
file_name=f"{temp_dir}{os.sep}",
)
if not isinstance(downloaded, str):
raise RuntimeError("Telegram media download returned no file")
transcript = await transcribe_file(
Path(downloaded),
ctx.manager.config.elevenlabs,
content_type=getattr(media, "mime_type", None),
)
for chunk in _split_transcript(transcript):
entity = MessageEntity(
type=MessageEntityType.BLOCKQUOTE,
offset=0,
length=_utf16_length(chunk),
expandable=True,
)
await ctx.reply(chunk, entities=[entity])
except Exception:
log.exception("Failed to transcribe media for message %s", getattr(ctx.message, "id", None))
await _dislike(ctx.message)