Add dual-prefix AI command
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textwrap import wrap
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.ai import AIInvocation, parse_ai_invocation
|
||||
from app.commands.core import CommandRegistry, normalize_command_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -31,6 +33,58 @@ def _display_command(command: str) -> str:
|
||||
return f"..{command}"
|
||||
|
||||
|
||||
def _chunk_text(text: str, limit: int = 3900) -> list[str]:
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
current_len = 0
|
||||
for paragraph in text.split("\n"):
|
||||
paragraph = paragraph.rstrip()
|
||||
piece = paragraph if not current else "\n" + paragraph
|
||||
if current_len + len(piece) <= limit:
|
||||
current.append(piece.lstrip("\n") if not current else piece)
|
||||
current_len += len(piece)
|
||||
continue
|
||||
if current:
|
||||
chunks.append("".join(current))
|
||||
current = []
|
||||
current_len = 0
|
||||
if len(paragraph) <= limit:
|
||||
current = [paragraph]
|
||||
current_len = len(paragraph)
|
||||
continue
|
||||
for line in wrap(paragraph, width=limit):
|
||||
if len(line) <= limit:
|
||||
chunks.append(line)
|
||||
else:
|
||||
chunks.extend([line[i : i + limit] for i in range(0, len(line), limit)])
|
||||
if current:
|
||||
chunks.append("".join(current))
|
||||
return [chunk for chunk in chunks if chunk]
|
||||
|
||||
|
||||
async def _reply_long(ctx: "CommandContext", text: str) -> None:
|
||||
chunks = _chunk_text(text)
|
||||
if not chunks:
|
||||
await ctx.reply("")
|
||||
return
|
||||
first = await ctx.reply(chunks[0])
|
||||
for chunk in chunks[1:]:
|
||||
await ctx.client.send_message(
|
||||
ctx.message.chat.id,
|
||||
chunk,
|
||||
reply_to_message_id=getattr(first, "id", None),
|
||||
)
|
||||
|
||||
|
||||
def _format_ai_help(ctx: "CommandContext") -> str:
|
||||
ai_service = ctx.manager.ai_service
|
||||
if ai_service is None:
|
||||
return ""
|
||||
return "AI models: " + ai_service.describe_models()
|
||||
|
||||
|
||||
def _format_permissions(ctx: "CommandContext") -> str:
|
||||
store = ctx.acl_store
|
||||
target = ctx.replied_user
|
||||
@@ -163,6 +217,9 @@ async def status(ctx: "CommandContext") -> None:
|
||||
f"allow_rules={stats['allow_rules']}, "
|
||||
f"deny_rules={stats['deny_rules']}"
|
||||
)
|
||||
ai_service = ctx.manager.ai_service
|
||||
if ai_service is not None:
|
||||
lines.append("AI: " + ai_service.describe_models())
|
||||
await ctx.reply("\n".join(lines))
|
||||
|
||||
|
||||
@@ -173,7 +230,12 @@ async def status(ctx: "CommandContext") -> None:
|
||||
category="system",
|
||||
)
|
||||
async def help_command(ctx: "CommandContext") -> None:
|
||||
await ctx.reply(ctx.manager.command_registry.render_help(ctx))
|
||||
lines = [ctx.manager.command_registry.render_help(ctx)]
|
||||
ai_help = _format_ai_help(ctx)
|
||||
if ai_help:
|
||||
lines.append("")
|
||||
lines.append(ai_help)
|
||||
await ctx.reply("\n".join(lines))
|
||||
|
||||
|
||||
@command_registry.command(
|
||||
@@ -209,12 +271,48 @@ async def deny(ctx: "CommandContext") -> None:
|
||||
@command_registry.command(
|
||||
"echo",
|
||||
"Echo text for granted users",
|
||||
usage="..echo <text>",
|
||||
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 <text>")
|
||||
await ctx.reply("Usage: .echo / ..echo <text>")
|
||||
return
|
||||
await ctx.reply(text)
|
||||
|
||||
|
||||
@command_registry.command(
|
||||
"ai",
|
||||
"Ask a configurable AI model",
|
||||
usage=".ai / ..ai [N] [--model] prompt",
|
||||
category="ai",
|
||||
).shared()
|
||||
async def ai(ctx: "CommandContext") -> None:
|
||||
ai_service = ctx.manager.ai_service
|
||||
if ai_service is None or ctx.manager.config.ai is None:
|
||||
await ctx.reply("AI is not configured")
|
||||
return
|
||||
|
||||
try:
|
||||
invocation = parse_ai_invocation(
|
||||
ctx.args,
|
||||
default_context_messages=ctx.manager.config.ai.context_messages,
|
||||
)
|
||||
selected_alias = ai_service.select_model_alias(invocation.model_flags)
|
||||
response = await ai_service.generate(
|
||||
ctx,
|
||||
AIInvocation(
|
||||
context_messages=invocation.context_messages,
|
||||
model_flags=[selected_alias],
|
||||
prompt=invocation.prompt,
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
await ctx.reply(str(exc))
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
await ctx.reply(str(exc))
|
||||
return
|
||||
|
||||
await _reply_long(ctx, response.text)
|
||||
|
||||
@@ -82,7 +82,8 @@ def shared_access_check() -> CommandCheck:
|
||||
return CheckResult.deny("Permission store is not initialized")
|
||||
if store.is_allowed(ctx.actor_id, ctx.command.name):
|
||||
return CheckResult.allow()
|
||||
return CheckResult.deny(f"No access to {ctx.command.prefix}{ctx.command.name}")
|
||||
required_prefix = ctx.command.expected_prefix(ctx.is_owner)
|
||||
return CheckResult.deny(f"No access to {required_prefix}{ctx.command.name}")
|
||||
|
||||
return CommandCheck("shared_access", _check, "Allow owner or ACL-granted users")
|
||||
|
||||
@@ -185,7 +186,8 @@ class CommandDefinition:
|
||||
name: str
|
||||
description: str
|
||||
handler: CommandHandler
|
||||
prefix: str = "."
|
||||
owner_prefix: str = "."
|
||||
guest_prefix: str | None = None
|
||||
aliases: tuple[str, ...] = ()
|
||||
usage: str | None = None
|
||||
category: str = "general"
|
||||
@@ -193,11 +195,21 @@ class CommandDefinition:
|
||||
|
||||
@property
|
||||
def is_shared(self) -> bool:
|
||||
return self.prefix == ".."
|
||||
return self.guest_prefix is not None
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return f"{self.prefix}{self.name}"
|
||||
if self.guest_prefix is None:
|
||||
return f"{self.owner_prefix}{self.name}"
|
||||
return f"{self.owner_prefix}{self.name} / {self.guest_prefix}{self.name}"
|
||||
|
||||
def expected_prefix(self, is_owner: bool) -> str:
|
||||
if self.guest_prefix is None:
|
||||
return self.owner_prefix
|
||||
return self.owner_prefix if is_owner else self.guest_prefix
|
||||
|
||||
def accepts_prefix(self, prefix: str, *, is_owner: bool) -> bool:
|
||||
return prefix == self.expected_prefix(is_owner)
|
||||
|
||||
def help_line(self, ctx: CommandContext | None = None) -> str:
|
||||
usage = self.usage or self.display_name
|
||||
@@ -231,15 +243,16 @@ class CommandBuilder:
|
||||
self.aliases = [normalize_command_name(alias) for alias in aliases]
|
||||
self.usage = usage
|
||||
self.category = category
|
||||
self.prefix = "."
|
||||
self.owner_prefix = "."
|
||||
self.guest_prefix: str | None = None
|
||||
self.checks: list[CommandCheck] = []
|
||||
|
||||
def private(self) -> "CommandBuilder":
|
||||
self.prefix = "."
|
||||
self.guest_prefix = None
|
||||
return self
|
||||
|
||||
def shared(self) -> "CommandBuilder":
|
||||
self.prefix = ".."
|
||||
self.guest_prefix = ".."
|
||||
return self
|
||||
|
||||
def alias(self, *aliases: str) -> "CommandBuilder":
|
||||
@@ -264,7 +277,7 @@ class CommandBuilder:
|
||||
|
||||
def build(self, handler: CommandHandler) -> CommandDefinition:
|
||||
default_checks: list[CommandCheck] = []
|
||||
if self.prefix == "..":
|
||||
if self.guest_prefix is not None:
|
||||
default_checks.append(shared_access_check())
|
||||
else:
|
||||
default_checks.append(owner_only_check())
|
||||
@@ -273,7 +286,8 @@ class CommandBuilder:
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
handler=handler,
|
||||
prefix=self.prefix,
|
||||
owner_prefix=self.owner_prefix,
|
||||
guest_prefix=self.guest_prefix,
|
||||
aliases=tuple(self.aliases),
|
||||
usage=self.usage,
|
||||
category=self.category,
|
||||
|
||||
@@ -71,8 +71,18 @@ class CommandDispatcher:
|
||||
await message.reply_text(f"Unknown command: {parsed.prefix}{parsed.alias}")
|
||||
return True
|
||||
|
||||
if parsed.prefix != definition.prefix:
|
||||
await message.reply_text(f"Use {definition.display_name} for this command")
|
||||
actor_id = getattr(getattr(message, "from_user", None), "id", None)
|
||||
owner_id = getattr(getattr(account, "me", None), "id", None)
|
||||
is_owner = owner_id is not None and actor_id == owner_id
|
||||
|
||||
expected_prefix = definition.expected_prefix(is_owner)
|
||||
if parsed.prefix != expected_prefix:
|
||||
if definition.is_shared:
|
||||
await message.reply_text(
|
||||
f"Use {expected_prefix}{definition.name} for this account role"
|
||||
)
|
||||
else:
|
||||
await message.reply_text(f"Use {definition.owner_prefix}{definition.name} for this command")
|
||||
return True
|
||||
|
||||
ctx = CommandContext(
|
||||
|
||||
Reference in New Issue
Block a user