feat(feishu): update card schema to 2.0 and simplify approval card structure docs(feishu): add documentation for card json schema 2.0 changes
500 lines
18 KiB
Python
500 lines
18 KiB
Python
"""Slash command handler for direct bot control."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import logging
|
||
import re
|
||
import uuid
|
||
from typing import Optional, Tuple
|
||
|
||
from agent.manager import manager
|
||
from agent.scheduler import scheduler
|
||
from agent.task_runner import task_runner
|
||
from agent.sdk_session import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
|
||
from orchestrator.agent import agent
|
||
from orchestrator.tools import set_current_user, get_current_chat
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Permission mode aliases (user-facing shorthand → internal CC mode)
|
||
_PERM_ALIASES: dict[str, str] = {
|
||
"bypass": "bypassPermissions",
|
||
"default": "default",
|
||
"edit": "acceptEdits",
|
||
"plan": "plan",
|
||
"auto": "dontAsk",
|
||
}
|
||
_PERM_LABELS: dict[str, str] = {
|
||
"default": "default",
|
||
"bypassPermissions": "bypass",
|
||
"acceptEdits": "edit",
|
||
"plan": "plan",
|
||
"dontAsk": "auto",
|
||
}
|
||
|
||
|
||
def _resolve_perm(alias: str) -> str | None:
|
||
"""Map user-facing alias to internal permission mode, or None if invalid."""
|
||
return _PERM_ALIASES.get(alias.lower().strip())
|
||
|
||
|
||
def _perm_label(mode: str) -> str:
|
||
"""Return short human-readable label for a permission mode."""
|
||
return _PERM_LABELS.get(mode, mode)
|
||
|
||
|
||
def parse_command(text: str) -> Optional[Tuple[str, str]]:
|
||
"""
|
||
Parse a bot command from text.
|
||
Returns (command, args) or None if not a command.
|
||
Commands must start with COMMAND_PREFIX (default "//").
|
||
"""
|
||
from config import COMMAND_PREFIX
|
||
text = text.strip()
|
||
if not text.startswith(COMMAND_PREFIX):
|
||
return None
|
||
# Strip the prefix, then split into command word and args
|
||
body = text[len(COMMAND_PREFIX):]
|
||
parts = body.split(None, 1)
|
||
cmd = COMMAND_PREFIX + parts[0].lower()
|
||
args = parts[1] if len(parts) > 1 else ""
|
||
return (cmd, args)
|
||
|
||
|
||
async def handle_command(user_id: str, text: str) -> Optional[str]:
|
||
"""
|
||
Handle a slash command. Returns the reply or None if not a command.
|
||
"""
|
||
parsed = parse_command(text)
|
||
if not parsed:
|
||
return None
|
||
|
||
cmd, args = parsed
|
||
logger.info("Command: %s args=%r user=...%s", cmd, args[:50], user_id[-8:])
|
||
|
||
# In ROUTER_MODE, only handle router-specific commands locally.
|
||
# Session commands (//status, //new, //close, etc.) fall through to node forwarding.
|
||
from config import ROUTER_MODE, COMMAND_PREFIX
|
||
P = COMMAND_PREFIX
|
||
if ROUTER_MODE and cmd not in (P+"nodes", P+"node", P+"help", P+"h", P+"?"):
|
||
return None
|
||
|
||
set_current_user(user_id)
|
||
|
||
if cmd in (P+"new", P+"n"):
|
||
return await _cmd_new(user_id, args)
|
||
elif cmd in (P+"list", P+"ls", P+"l", P+"status"):
|
||
return await _cmd_status(user_id)
|
||
elif cmd in (P+"close", P+"c"):
|
||
return await _cmd_close(user_id, args)
|
||
elif cmd in (P+"switch", P+"s"):
|
||
return await _cmd_switch(user_id, args)
|
||
elif cmd == P+"retry":
|
||
return await _cmd_retry(user_id)
|
||
elif cmd in (P+"help", P+"h", P+"?"):
|
||
return _cmd_help()
|
||
elif cmd == P+"direct":
|
||
return _cmd_direct(user_id)
|
||
elif cmd == P+"smart":
|
||
return _cmd_smart(user_id)
|
||
elif cmd == P+"tasks":
|
||
return _cmd_tasks()
|
||
elif cmd == P+"shell":
|
||
return await _cmd_shell(args)
|
||
elif cmd == P+"remind":
|
||
return await _cmd_remind(args)
|
||
elif cmd == P+"perm":
|
||
return await _cmd_perm(user_id, args)
|
||
elif cmd in (P+"stop", P+"interrupt"):
|
||
return await _cmd_stop(user_id)
|
||
elif cmd in (P+"progress", P+"prog", P+"p"):
|
||
return await _cmd_progress(user_id)
|
||
elif cmd in (P+"nodes", P+"node"):
|
||
return await _cmd_nodes(user_id, args)
|
||
else:
|
||
return None
|
||
|
||
|
||
async def _cmd_new(user_id: str, args: str) -> str:
|
||
"""Create a new session."""
|
||
if not args:
|
||
return "Usage: /new <project_dir> [initial_message] [--perm MODE]\nModes: default, edit, plan, bypass, auto"
|
||
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("working_dir", nargs="?", help="Project directory")
|
||
parser.add_argument("rest", nargs="*", help="Initial message")
|
||
parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds")
|
||
parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass, auto")
|
||
|
||
try:
|
||
parsed = parser.parse_args(args.split())
|
||
except SystemExit:
|
||
return "Usage: /new <project_dir> [initial_message] [--idle N] [--perm MODE]"
|
||
|
||
if not parsed.working_dir:
|
||
return "Error: project_dir is required"
|
||
|
||
permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE
|
||
if permission_mode is None:
|
||
return f"Invalid --perm. Valid modes: default, edit, plan, bypass, auto"
|
||
|
||
working_dir = parsed.working_dir
|
||
initial_msg = " ".join(parsed.rest) if parsed.rest else None
|
||
|
||
from orchestrator.tools import _resolve_dir
|
||
|
||
try:
|
||
resolved = _resolve_dir(working_dir)
|
||
except ValueError as e:
|
||
return f"Error: {e}"
|
||
|
||
import uuid as _uuid
|
||
conv_id = str(_uuid.uuid4())[:8]
|
||
chat_id = get_current_chat()
|
||
await manager.create(
|
||
conv_id,
|
||
str(resolved),
|
||
owner_id=user_id,
|
||
idle_timeout=parsed.idle or 1800,
|
||
permission_mode=permission_mode,
|
||
chat_id=chat_id,
|
||
)
|
||
agent._active_conv[user_id] = conv_id
|
||
|
||
response = None
|
||
if initial_msg:
|
||
response = await manager.send(conv_id, initial_msg, user_id=user_id)
|
||
|
||
if chat_id:
|
||
from bot.feishu import send_card, send_text, build_sessions_card
|
||
sessions = manager.list_sessions(user_id=user_id)
|
||
routing_mode = "Direct 🟢" if agent.get_passthrough(user_id) else "Smart ⚪"
|
||
card = build_sessions_card(sessions, conv_id, routing_mode)
|
||
await send_card(chat_id, "chat_id", card)
|
||
if initial_msg and response:
|
||
await send_text(chat_id, "chat_id", response)
|
||
return ""
|
||
|
||
perm_label = _perm_label(permission_mode)
|
||
reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]"
|
||
if initial_msg and response:
|
||
reply += f"\n\n{response}"
|
||
return reply
|
||
|
||
|
||
async def _cmd_status(user_id: str) -> str:
|
||
"""Show status: sessions and current mode."""
|
||
sessions = manager.list_sessions(user_id=user_id)
|
||
active = agent.get_active_conv(user_id)
|
||
passthrough = agent.get_passthrough(user_id)
|
||
mode = "Direct 🟢" if passthrough else "Smart ⚪"
|
||
|
||
chat_id = get_current_chat()
|
||
if chat_id:
|
||
from bot.feishu import send_card, build_sessions_card
|
||
card = build_sessions_card(sessions, active, mode)
|
||
await send_card(chat_id, "chat_id", card)
|
||
return ""
|
||
|
||
if not sessions:
|
||
return "No active sessions."
|
||
lines = ["**Your Sessions:**\n"]
|
||
for i, s in enumerate(sessions, 1):
|
||
marker = "→ " if s["conv_id"] == active else " "
|
||
perm = _perm_label(s.get("permission_mode", DEFAULT_PERMISSION_MODE))
|
||
lines.append(f"{marker}{i}. `{s['conv_id']}` - `{s['cwd']}` [{perm}]")
|
||
lines.append(f"\n**Mode:** {mode}")
|
||
lines.append("Use `//switch <n>` to activate a session.")
|
||
lines.append("Use `//direct` or `//smart` to change mode.")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _cmd_close(user_id: str, args: str) -> str:
|
||
"""Close a session."""
|
||
# If a specific conv_id is given by name (not a number), resolve it directly.
|
||
if args:
|
||
try:
|
||
int(args)
|
||
by_number = True
|
||
except ValueError:
|
||
by_number = False
|
||
|
||
if not by_number:
|
||
# Explicit conv_id given — look it up directly (may belong to another user).
|
||
conv_id = args.strip()
|
||
try:
|
||
success = await manager.close(conv_id, user_id=user_id)
|
||
if success:
|
||
if agent.get_active_conv(user_id) == conv_id:
|
||
agent._active_conv[user_id] = None
|
||
return f"✓ Closed session `{conv_id}`"
|
||
else:
|
||
return f"Session `{conv_id}` not found."
|
||
except PermissionError as e:
|
||
return str(e)
|
||
|
||
sessions = manager.list_sessions(user_id=user_id)
|
||
if not sessions:
|
||
return "No sessions to close."
|
||
|
||
if args:
|
||
try:
|
||
idx = int(args) - 1
|
||
if 0 <= idx < len(sessions):
|
||
conv_id = sessions[idx]["conv_id"]
|
||
else:
|
||
return f"Invalid session number. Use 1-{len(sessions)}."
|
||
except ValueError:
|
||
conv_id = args.strip()
|
||
else:
|
||
conv_id = agent.get_active_conv(user_id)
|
||
if not conv_id:
|
||
return "No active session. Use `//close <conv_id>` or `//close <n>`."
|
||
|
||
try:
|
||
success = await manager.close(conv_id, user_id=user_id)
|
||
if success:
|
||
if agent.get_active_conv(user_id) == conv_id:
|
||
agent._active_conv[user_id] = None
|
||
return f"✓ Closed session `{conv_id}`"
|
||
else:
|
||
return f"Session `{conv_id}` not found."
|
||
except PermissionError as e:
|
||
return str(e)
|
||
|
||
|
||
async def _cmd_switch(user_id: str, args: str) -> str:
|
||
"""Switch to a different session."""
|
||
sessions = manager.list_sessions(user_id=user_id)
|
||
if not sessions:
|
||
return "No sessions available."
|
||
|
||
if not args:
|
||
return "Usage: /switch <n>\n" + await _cmd_status(user_id)
|
||
|
||
try:
|
||
idx = int(args) - 1
|
||
if 0 <= idx < len(sessions):
|
||
conv_id = sessions[idx]["conv_id"]
|
||
agent._active_conv[user_id] = conv_id
|
||
return f"✓ Switched to session `{conv_id}` ({sessions[idx]['cwd']})"
|
||
else:
|
||
return f"Invalid session number. Use 1-{len(sessions)}."
|
||
except ValueError:
|
||
return f"Invalid number: {args}"
|
||
|
||
|
||
async def _cmd_perm(user_id: str, args: str) -> str:
|
||
"""Change the permission mode of the active (or specified) session."""
|
||
parts = args.split()
|
||
if not parts:
|
||
return (
|
||
"Usage: /perm <mode> [conv_id]\n"
|
||
"Modes: default, edit, plan, bypass, auto\n"
|
||
" default — default mode\n"
|
||
" edit — auto-accept file edits, confirm shell commands\n"
|
||
" plan — plan only, no writes\n"
|
||
" bypass — skip all permission checks\n"
|
||
" auto — allow all tools, don't ask"
|
||
)
|
||
|
||
alias = parts[0]
|
||
conv_id = parts[1] if len(parts) > 1 else agent.get_active_conv(user_id)
|
||
|
||
permission_mode = _resolve_perm(alias)
|
||
if permission_mode is None:
|
||
return f"Unknown mode '{alias}'. Valid: default, edit, plan, bypass"
|
||
|
||
if not conv_id:
|
||
return "No active session. Use `//perm <mode> <conv_id>` or activate a session first."
|
||
|
||
try:
|
||
manager.set_permission_mode(conv_id, permission_mode, user_id=user_id)
|
||
except KeyError:
|
||
return f"Session `{conv_id}` not found."
|
||
except PermissionError as e:
|
||
return str(e)
|
||
|
||
return f"✓ Session `{conv_id}` permission mode set to **{_perm_label(permission_mode)}**"
|
||
|
||
|
||
async def _cmd_retry(user_id: str) -> str:
|
||
"""Retry the last message (placeholder - needs history tracking)."""
|
||
return "Retry not yet implemented. Just send your message again."
|
||
|
||
|
||
def _cmd_direct(user_id: str) -> str:
|
||
"""Enable direct mode - messages go straight to Claude Code."""
|
||
conv = agent.get_active_conv(user_id)
|
||
if not conv:
|
||
return "No active session. Use `//new` or `//switch` first."
|
||
agent.set_passthrough(user_id, True)
|
||
return f"✓ Direct mode ON. Messages go directly to session `{conv}`."
|
||
|
||
|
||
def _cmd_smart(user_id: str) -> str:
|
||
"""Enable smart mode - messages go through LLM for routing."""
|
||
agent.set_passthrough(user_id, False)
|
||
return "✓ Smart mode ON. Messages go through LLM for intelligent routing."
|
||
|
||
|
||
def _cmd_tasks() -> str:
|
||
"""List background tasks."""
|
||
tasks = task_runner.list_tasks()
|
||
if not tasks:
|
||
return "No background tasks."
|
||
|
||
lines = ["**Background Tasks:**\n"]
|
||
for t in tasks:
|
||
status_emoji = {"completed": "✅", "failed": "❌", "running": "⏳", "pending": "⏸️"}.get(
|
||
t["status"], "❓"
|
||
)
|
||
lines.append(f"{status_emoji} #{t['task_id']} - {t['description'][:50]} ({t['elapsed']}s)")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _cmd_shell(args: str) -> str:
|
||
"""Execute a shell command directly."""
|
||
if not args:
|
||
return "Usage: /shell <command>\nExample: /shell git status"
|
||
|
||
from orchestrator.tools import ShellTool, get_current_chat
|
||
|
||
tool = ShellTool()
|
||
result = await tool._arun(command=args)
|
||
try:
|
||
data = json.loads(result)
|
||
if "error" in data:
|
||
return f"❌ {data['error']}"
|
||
output = []
|
||
if data.get("stdout"):
|
||
output.append(data["stdout"])
|
||
if data.get("stderr"):
|
||
output.append(f"[stderr] {data['stderr']}")
|
||
output.append(f"[exit code: {data.get('exit_code', '?')}]")
|
||
return "\n".join(output) if output else "(no output)"
|
||
except json.JSONDecodeError:
|
||
return result
|
||
|
||
|
||
async def _cmd_remind(args: str) -> str:
|
||
"""Set a reminder."""
|
||
if not args:
|
||
return "Usage: /remind <time> <message>\nExample: /remind 10m check the build\nTime format: 30s, 10m, 1h"
|
||
|
||
parts = args.split(None, 1)
|
||
if len(parts) < 2:
|
||
return "Usage: /remind <time> <message>\nExample: /remind 10m check the build"
|
||
|
||
time_str, message = parts
|
||
match = re.match(r'^(\d+)(s|m|h)$', time_str.lower())
|
||
if not match:
|
||
return "Invalid time format. Use: 30s, 10m, 1h"
|
||
|
||
value = int(match.group(1))
|
||
unit = match.group(2)
|
||
seconds = value * {'s': 1, 'm': 60, 'h': 3600}[unit]
|
||
|
||
chat_id = get_current_chat()
|
||
job_id = await scheduler.schedule_once(
|
||
delay_seconds=seconds,
|
||
message=message,
|
||
notify_chat_id=chat_id,
|
||
)
|
||
|
||
return f"⏰ Reminder #{job_id} set for {value}{unit} from now"
|
||
|
||
|
||
async def _cmd_stop(user_id: str) -> str:
|
||
"""Interrupt the current task in the active session."""
|
||
conv_id = agent.get_active_conv(user_id)
|
||
if not conv_id:
|
||
return "No active session."
|
||
try:
|
||
success = await manager.interrupt(conv_id, user_id)
|
||
return "✓ Interrupted" if success else "No active task."
|
||
except Exception as e:
|
||
return f"Error: {e}"
|
||
|
||
|
||
async def _cmd_progress(user_id: str) -> str:
|
||
"""Show progress of the active session."""
|
||
conv_id = agent.get_active_conv(user_id)
|
||
if not conv_id:
|
||
return "No active session."
|
||
progress = manager.get_progress(conv_id, user_id)
|
||
if not progress:
|
||
return "Session not found."
|
||
if not progress.busy:
|
||
if progress.last_result:
|
||
return f"✅ 已完成\n\n{progress.last_result[:500]}"
|
||
return "空闲中,无正在执行的任务。"
|
||
elapsed = int(progress.elapsed_seconds)
|
||
tools = ", ".join(progress.tool_calls[-3:]) if progress.tool_calls else "none"
|
||
pending = ""
|
||
if progress.pending_approval:
|
||
pending = f"\n⚠️ 等待审批: {progress.pending_approval}"
|
||
return f"⏳ 执行中 ({elapsed}s)\n最近工具: {tools}{pending}"
|
||
|
||
|
||
async def _cmd_nodes(user_id: str, args: str) -> str:
|
||
"""List nodes or switch active node."""
|
||
from config import ROUTER_MODE
|
||
if not ROUTER_MODE:
|
||
return "Not in router mode."
|
||
|
||
from router.nodes import get_node_registry
|
||
registry = get_node_registry()
|
||
|
||
if args:
|
||
args = args.strip()
|
||
if registry.set_active_node(user_id, args):
|
||
return f"✓ Active node set to: {args}"
|
||
return f"Error: Node '{args}' not found"
|
||
|
||
nodes = registry.list_nodes()
|
||
if not nodes:
|
||
return "No nodes connected."
|
||
|
||
active_node_id = None
|
||
active_node = registry.get_active_node(user_id)
|
||
if active_node:
|
||
active_node_id = active_node.node_id
|
||
|
||
lines = ["**Connected Nodes:**\n"]
|
||
for n in nodes:
|
||
marker = "→ " if n["node_id"] == active_node_id else " "
|
||
status = "🟢" if n["status"] == "online" else "🔴"
|
||
lines.append(f"{marker}{n['display_name']} {status} sessions={n['sessions']}")
|
||
lines.append("\nUse `//node <name>` to switch active node.")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _cmd_help() -> str:
|
||
"""Show help."""
|
||
from config import COMMAND_PREFIX as P
|
||
return f"""**Commands:** (prefix: `{P}`)
|
||
{P}new <dir> [msg] [--idle N] [--perm MODE] - Create session (alias: {P}n)
|
||
{P}list - Show sessions and current mode (alias: {P}ls, {P}l, {P}status)
|
||
{P}close [n] - Close session (active or by number) (alias: {P}c)
|
||
{P}switch <n> - Switch to session by number (alias: {P}s)
|
||
{P}perm <mode> [conv_id] - Set permission mode (alias: {P}perm)
|
||
{P}stop - Interrupt the current task (alias: {P}interrupt)
|
||
{P}progress - Show task progress (alias: {P}p)
|
||
{P}direct - Direct mode: messages → Claude Code
|
||
{P}smart - Smart mode: messages → LLM routing (default)
|
||
{P}shell <cmd> - Run shell command
|
||
{P}remind <time> <msg> - Set reminder (e.g. {P}remind 10m check build)
|
||
{P}tasks - List background tasks
|
||
{P}nodes - List connected host nodes
|
||
{P}node <name> - Switch active node
|
||
{P}help - Show this help (alias: {P}h, {P}?)
|
||
|
||
**Permission modes** (used by {P}perm and {P}new --perm):
|
||
default — 默认模式,需审批工具调用
|
||
edit — 自动接受文件编辑,shell 命令仍需确认
|
||
plan — 只规划、不执行任何写操作
|
||
bypass — 跳过所有权限确认
|
||
auto — 允许所有工具,不询问""" |