OpenRouter is used when no provider flag is set. Claude is selected with .cl, and GLM stays available via .glm.
490 lines
15 KiB
Python
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)
|