feat: 实现用户权限控制、会话管理和审计日志功能
- 添加用户权限检查功能,支持配置允许使用的用户列表 - 实现会话管理功能,包括会话创建、关闭、列表和切换 - 新增审计日志模块,记录所有交互信息 - 改进WebSocket连接,增加自动重连机制 - 添加健康检查端点,包含Claude服务可用性测试 - 实现会话持久化功能,重启后恢复会话状态 - 增加命令行功能支持,包括/new、/list、/close等命令 - 优化消息处理流程,支持直接传递模式
This commit is contained in:
+191
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user