feat: 实现用户权限控制、会话管理和审计日志功能

- 添加用户权限检查功能,支持配置允许使用的用户列表
- 实现会话管理功能,包括会话创建、关闭、列表和切换
- 新增审计日志模块,记录所有交互信息
- 改进WebSocket连接,增加自动重连机制
- 添加健康检查端点,包含Claude服务可用性测试
- 实现会话持久化功能,重启后恢复会话状态
- 增加命令行功能支持,包括/new、/list、/close等命令
- 优化消息处理流程,支持直接传递模式
This commit is contained in:
Yuyao Huang (Sam)
2026-03-28 08:39:32 +08:00
parent 29c0f2e403
commit 6307deb701
11 changed files with 921 additions and 222 deletions
+191
View File
@@ -0,0 +1,191 @@
"""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 orchestrator.agent import agent
from orchestrator.tools import set_current_user, get_current_user
logger = logging.getLogger(__name__)
def parse_command(text: str) -> Optional[Tuple[str, str]]:
"""
Parse a slash command from text.
Returns (command, args) or None if not a command.
"""
text = text.strip()
if not text.startswith("/"):
return None
parts = text.split(None, 1)
cmd = 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:])
set_current_user(user_id)
if cmd in ("/new", "/n"):
return await _cmd_new(user_id, args)
elif cmd in ("/list", "/ls", "/l"):
return await _cmd_list(user_id)
elif cmd in ("/close", "/c"):
return await _cmd_close(user_id, args)
elif cmd in ("/switch", "/s"):
return await _cmd_switch(user_id, args)
elif cmd == "/retry":
return await _cmd_retry(user_id)
elif cmd in ("/help", "/h", "/?"):
return _cmd_help()
else:
return f"Unknown command: {cmd}\n\n{_cmd_help()}"
async def _cmd_new(user_id: str, args: str) -> str:
"""Create a new session."""
if not args:
return "Usage: /new <project_dir> [initial_message] [--timeout N]\nExample: /new todo_app fix the bug --timeout 600"
parser = argparse.ArgumentParser()
parser.add_argument("working_dir", nargs="?", help="Project directory")
parser.add_argument("rest", nargs="*", help="Initial message")
parser.add_argument("--timeout", type=int, default=None, help="CC timeout in seconds")
parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds")
try:
parsed = parser.parse_args(args.split())
except SystemExit:
return "Usage: /new <project_dir> [initial_message] [--timeout N] [--idle N]"
if not parsed.working_dir:
return "Error: project_dir is required"
working_dir = parsed.working_dir
initial_msg = " ".join(parsed.rest) if parsed.rest else None
from orchestrator.tools import CreateConversationTool
tool = CreateConversationTool()
result = await tool._arun(
working_dir=working_dir,
initial_message=initial_msg,
cc_timeout=parsed.timeout,
idle_timeout=parsed.idle,
)
try:
data = json.loads(result)
if "error" in data:
return f"Error: {data['error']}"
conv_id = data.get("conv_id", "")
agent._active_conv[user_id] = conv_id
cwd = data.get("working_dir", working_dir)
reply = f"✓ Created session `{conv_id}` in `{cwd}`"
if parsed.timeout:
reply += f" (timeout: {parsed.timeout}s)"
if initial_msg:
reply += f"\n\nSent: {initial_msg[:100]}..."
return reply
except Exception:
return result
async def _cmd_list(user_id: str) -> str:
"""List all sessions for this user."""
sessions = manager.list_sessions(user_id=user_id)
if not sessions:
return "No active sessions."
active = agent.get_active_conv(user_id)
lines = ["**Your Sessions:**\n"]
for i, s in enumerate(sessions, 1):
marker = "" if s["conv_id"] == active else " "
lines.append(f"{marker}{i}. `{s['conv_id']}` - `{s['cwd']}`")
lines.append("\nUse `/switch <n>` to activate a session.")
return "\n".join(lines)
async def _cmd_close(user_id: str, args: str) -> str:
"""Close a session."""
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_list(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_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_help() -> str:
"""Show help."""
return """**Commands:**
/new <dir> [msg] [--timeout N] [--idle N] - Create session
/list - List your sessions
/close [n] - Close session (active or by number)
/switch <n> - Switch to session by number
/retry - Retry last message
/help - Show this help"""
+221 -20
View File
@@ -2,10 +2,14 @@
from __future__ import annotations
import asyncio
import json
import logging
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
CreateMessageRequest,
CreateMessageRequestBody,
)
@@ -14,8 +18,7 @@ from config import FEISHU_APP_ID, FEISHU_APP_SECRET
logger = logging.getLogger(__name__)
# Max Feishu text message length
MAX_TEXT_LEN = 4000
MAX_TEXT_LEN = 3900
def _make_client() -> lark.Client:
@@ -31,54 +34,252 @@ def _make_client() -> lark.Client:
_client = _make_client()
def _truncate(text: str) -> str:
def _split_message(text: str) -> list[str]:
if len(text) <= MAX_TEXT_LEN:
return text
return text[: MAX_TEXT_LEN - 20] + "\n...[truncated]"
return [text]
parts: list[str] = []
remaining = text
while remaining:
if len(remaining) <= MAX_TEXT_LEN:
parts.append(remaining)
break
chunk = remaining[:MAX_TEXT_LEN]
last_newline = chunk.rfind("\n")
if last_newline > MAX_TEXT_LEN // 2:
chunk = remaining[:last_newline + 1]
parts.append(chunk)
remaining = remaining[len(chunk):]
total = len(parts)
headered_parts = []
for i, part in enumerate(parts, 1):
headered_parts.append(f"[{i}/{total}]\n{part}")
return headered_parts
async def send_text(receive_id: str, receive_id_type: str, text: str) -> None:
"""
Send a plain-text message to a Feishu chat or user.
Automatically splits long messages into multiple parts with [1/N] headers.
Args:
receive_id: chat_id or open_id depending on receive_id_type.
receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id".
text: message content.
"""
import json as _json
truncated = _truncate(text)
logger.debug(
"[feishu] send_text to=%s type=%s len=%d/%d text=%r",
receive_id, receive_id_type, len(truncated), len(text), truncated[:120],
)
content = _json.dumps({"text": truncated}, ensure_ascii=False)
parts = _split_message(text)
loop = asyncio.get_event_loop()
for i, part in enumerate(parts):
logger.debug(
"[feishu] send_text to=%s type=%s part=%d/%d len=%d",
receive_id, receive_id_type, i + 1, len(parts), len(part),
)
content = json.dumps({"text": part}, ensure_ascii=False)
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type("text")
.content(content)
.build()
)
.build()
)
response = await loop.run_in_executor(
None,
lambda: _client.im.v1.message.create(request),
)
if not response.success():
logger.error(
"Feishu send_text failed: code=%s msg=%s",
response.code,
response.msg,
)
return
else:
logger.debug("Sent message part %d/%d to %s (%s)", i + 1, len(parts), receive_id, receive_id_type)
if len(parts) > 1 and i < len(parts) - 1:
await asyncio.sleep(0.3)
async def send_card(receive_id: str, receive_id_type: str, title: str, content: str, buttons: list[dict] | None = None) -> None:
"""
Send an interactive card message.
Args:
receive_id: chat_id or open_id
receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id"
title: Card title
content: Card content (markdown supported)
buttons: List of button dicts with "text" and "value" keys
"""
elements = [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": content,
},
},
]
if buttons:
actions = []
for btn in buttons:
actions.append({
"tag": "button",
"text": {"tag": "plain_text", "content": btn.get("text", "Button")},
"type": "primary",
"value": btn.get("value", {}),
})
elements.append({"tag": "action", "actions": actions})
card = {
"type": "template",
"data": {
"template_id": "AAqkz9****",
"template_variable": {
"title": title,
"elements": elements,
},
},
}
card_content = {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": elements,
}
loop = asyncio.get_event_loop()
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type("text")
.content(content)
.msg_type("interactive")
.content(json.dumps(card_content, ensure_ascii=False))
.build()
)
.build()
)
import asyncio
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: _client.im.v1.message.create(request),
)
if not response.success():
logger.error("Feishu send_card failed: code=%s msg=%s", response.code, response.msg)
else:
logger.debug("Sent card to %s (%s)", receive_id, receive_id_type)
async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_type: str = "stream") -> None:
"""
Upload a local file to Feishu and send it as a file message.
Args:
receive_id: chat_id or open_id depending on receive_id_type.
receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id".
file_path: Absolute path to the local file to send.
file_type: Feishu file type — "stream" (generic), "opus", "mp4", "pdf", "doc", "xls", "ppt".
"""
import os
path = os.path.abspath(file_path)
file_name = os.path.basename(path)
loop = asyncio.get_event_loop()
# Step 1: Upload file → get file_key
with open(path, "rb") as f:
file_data = f.read()
def _upload():
req = (
CreateFileRequest.builder()
.request_body(
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(file_name)
.file(file_data)
.build()
)
.build()
)
return _client.im.v1.file.create(req)
upload_resp = await loop.run_in_executor(None, _upload)
if not upload_resp.success():
logger.error(
"Feishu send_text failed: code=%s msg=%s",
response.code,
response.msg,
"Feishu file upload failed: code=%s msg=%s",
upload_resp.code,
upload_resp.msg,
)
return
file_key = upload_resp.data.file_key
logger.debug("Uploaded file %r → file_key=%r", file_name, file_key)
# Step 2: Send file message using the file_key
content = json.dumps({"file_key": file_key}, ensure_ascii=False)
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type("file")
.content(content)
.build()
)
.build()
)
send_resp = await loop.run_in_executor(
None,
lambda: _client.im.v1.message.create(request),
)
if not send_resp.success():
logger.error(
"Feishu send_file failed: code=%s msg=%s",
send_resp.code,
send_resp.msg,
)
else:
logger.debug("Sent message to %s (%s)", receive_id, receive_id_type)
logger.debug("Sent file %r to %s (%s)", file_name, receive_id, receive_id_type)
def build_session_card(conv_id: str, cwd: str, started: bool) -> dict:
"""Build a session status card."""
status = "🟢 Active" if started else "🟡 Ready"
content = f"**Session ID:** `{conv_id}`\n**Directory:** `{cwd}`\n**Status:** {status}"
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": "Claude Code Session"},
"template": "turquoise",
},
"elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": content}},
{"tag": "hr"},
{
"tag": "action",
"actions": [
{"tag": "button", "text": {"tag": "plain_text", "content": "Continue"}, "type": "primary", "value": {"action": "continue", "conv_id": conv_id}},
{"tag": "button", "text": {"tag": "plain_text", "content": "Close"}, "type": "default", "value": {"action": "close", "conv_id": conv_id}},
],
},
],
}
+57 -25
View File
@@ -6,30 +6,41 @@ import asyncio
import json
import logging
import threading
import time
import lark_oapi as lark
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
from bot.commands import handle_command
from bot.feishu import send_text
from config import FEISHU_APP_ID, FEISHU_APP_SECRET
from config import FEISHU_APP_ID, FEISHU_APP_SECRET, is_user_allowed
from orchestrator.agent import agent
logger = logging.getLogger(__name__)
# Keep a reference to the running event loop so sync callbacks can schedule coroutines
_main_loop: asyncio.AbstractEventLoop | None = None
_ws_connected: bool = False
_last_message_time: float = 0.0
_reconnect_count: int = 0
def get_ws_status() -> dict:
"""Return WebSocket connection status."""
return {
"connected": _ws_connected,
"last_message_time": _last_message_time,
"reconnect_count": _reconnect_count,
}
def _handle_message(data: P2ImMessageReceiveV1) -> None:
"""
Synchronous callback invoked by the lark-oapi SDK on every incoming message.
We schedule async work onto the main event loop.
"""
global _last_message_time
_last_message_time = time.time()
try:
message = data.event.message
sender = data.event.sender
# Log raw event for debugging
logger.debug(
"event type=%r chat_type=%r content=%r",
getattr(message, "message_type", None),
@@ -37,18 +48,15 @@ def _handle_message(data: P2ImMessageReceiveV1) -> None:
(getattr(message, "content", None) or "")[:100],
)
# Only handle text messages
if message.message_type != "text":
logger.info("Skipping non-text message_type=%r", message.message_type)
return
# Extract fields
chat_id: str = message.chat_id
raw_content: str = message.content or "{}"
content_obj = json.loads(raw_content)
text: str = content_obj.get("text", "").strip()
# Strip @mentions (Feishu injects "@bot_name " at start of group messages)
import re
text = re.sub(r"@\S+\s*", "", text).strip()
@@ -62,14 +70,12 @@ def _handle_message(data: P2ImMessageReceiveV1) -> None:
logger.info("✉ ...%s%r", open_id[-8:], text[:80])
# Use open_id as user identifier for per-user history in the orchestrator
user_id = open_id or chat_id
if _main_loop is None:
logger.error("Main event loop not set; cannot process message")
return
# Schedule async processing; fire-and-forget
asyncio.run_coroutine_threadsafe(
_process_message(user_id, chat_id, text),
_main_loop,
@@ -79,9 +85,16 @@ def _handle_message(data: P2ImMessageReceiveV1) -> None:
async def _process_message(user_id: str, chat_id: str, text: str) -> None:
"""Run the orchestration agent and send the reply back to Feishu."""
"""Process message: check allowlist, then commands, then agent."""
try:
reply = await agent.run(user_id, text)
if not is_user_allowed(user_id):
logger.warning("Rejected message from unauthorized user: ...%s", user_id[-8:])
await send_text(chat_id, "chat_id", "Sorry, you are not authorized to use this bot.")
return
reply = await handle_command(user_id, text)
if reply is None:
reply = await agent.run(user_id, text)
if reply:
await send_text(chat_id, "chat_id", reply)
except Exception:
@@ -112,19 +125,38 @@ def start_websocket_client(loop: asyncio.AbstractEventLoop) -> None:
global _main_loop
_main_loop = loop
event_handler = build_event_handler()
def _run_with_reconnect() -> None:
global _ws_connected, _reconnect_count
backoff = 1.0
max_backoff = 60.0
ws_client = lark.ws.Client(
FEISHU_APP_ID,
FEISHU_APP_SECRET,
event_handler=event_handler,
log_level=lark.LogLevel.INFO,
)
while True:
try:
_ws_connected = False
event_handler = build_event_handler()
ws_client = lark.ws.Client(
FEISHU_APP_ID,
FEISHU_APP_SECRET,
event_handler=event_handler,
log_level=lark.LogLevel.INFO,
)
def _run() -> None:
logger.info("Starting Feishu long-connection client...")
ws_client.start() # blocks until disconnected
logger.info("Starting Feishu long-connection client...")
_ws_connected = True
_reconnect_count += 1
ws_client.start()
logger.warning("WebSocket disconnected, will reconnect...")
thread = threading.Thread(target=_run, daemon=True, name="feishu-ws")
except Exception as e:
logger.error("WebSocket error: %s", e)
finally:
_ws_connected = False
logger.info("Reconnecting in %.1fs (attempt %d)...", backoff, _reconnect_count)
time.sleep(backoff)
backoff = min(backoff * 2, max_backoff)
thread = threading.Thread(target=_run_with_reconnect, daemon=True, name="feishu-ws")
thread.start()
logger.info("Feishu WebSocket thread started")