feat: 初始化项目基础结构

添加项目基础文件和目录结构,包括:
- 初始化空包目录(bot/agent/orchestrator)
- 配置文件(config.py)和示例(keyring.example.yaml)
- 依赖文件(requirements.txt)
- 主程序入口(main.py)
- 调试脚本(debug_test.py)
- 文档说明(README.md)
- Git忽略文件(.gitignore)
- 核心功能模块(pty_process/manager/handler/feishu等)
This commit is contained in:
Yuyao Huang (Sam)
2026-03-28 07:44:44 +08:00
commit 0eb29f2dcc
16 changed files with 981 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# empty init
+79
View File
@@ -0,0 +1,79 @@
"""Feishu API client: send messages back to users."""
from __future__ import annotations
import logging
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
)
from config import FEISHU_APP_ID, FEISHU_APP_SECRET
logger = logging.getLogger(__name__)
# Max Feishu text message length
MAX_TEXT_LEN = 4000
def _make_client() -> lark.Client:
return (
lark.Client.builder()
.app_id(FEISHU_APP_ID)
.app_secret(FEISHU_APP_SECRET)
.log_level(lark.LogLevel.WARNING)
.build()
)
_client = _make_client()
def _truncate(text: str) -> str:
if len(text) <= MAX_TEXT_LEN:
return text
return text[: MAX_TEXT_LEN - 20] + "\n...[truncated]"
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.
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
content = _json.dumps({"text": _truncate(text)}, 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()
)
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_text failed: code=%s msg=%s",
response.code,
response.msg,
)
else:
logger.debug("Sent message to %s (%s)", receive_id, receive_id_type)
+130
View File
@@ -0,0 +1,130 @@
"""Feishu event handler using lark-oapi long-connection (WebSocket) mode."""
from __future__ import annotations
import asyncio
import json
import logging
import threading
import lark_oapi as lark
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
from bot.feishu import send_text
from config import FEISHU_APP_ID, FEISHU_APP_SECRET
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
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.
"""
try:
message = data.event.message
sender = data.event.sender
# Log raw event for debugging
logger.info(
"RAW event: type=%r content=%r chat_type=%r",
getattr(message, "message_type", None),
getattr(message, "content", None),
getattr(message, "chat_type", None),
)
# 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()
open_id: str = ""
if sender and sender.sender_id:
open_id = sender.sender_id.open_id or ""
if not text:
logger.info("Empty text after stripping, ignoring")
return
logger.info("Received message from %s in %s: %r", open_id, chat_id, 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,
)
except Exception:
logger.exception("Error in _handle_message")
async def _process_message(user_id: str, chat_id: str, text: str) -> None:
"""Run the orchestration agent and send the reply back to Feishu."""
try:
reply = await agent.run(user_id, text)
if reply:
await send_text(chat_id, "chat_id", reply)
except Exception:
logger.exception("Error processing message for user %s", user_id)
def _handle_any(data: lark.CustomizedEvent) -> None:
"""Catch-all handler to log any event the SDK receives."""
logger.info("RAW CustomizedEvent: %s", lark.JSON.marshal(data)[:500])
def build_event_handler() -> lark.EventDispatcherHandler:
"""Construct the EventDispatcherHandler with all registered callbacks."""
handler = (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(_handle_message)
.register_p1_customized_event("im.message.receive_v1", _handle_any)
.build()
)
return handler
def start_websocket_client(loop: asyncio.AbstractEventLoop) -> None:
"""
Start the lark-oapi WebSocket long-connection client in a background thread.
Must be called after the asyncio event loop is running.
"""
global _main_loop
_main_loop = loop
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
thread = threading.Thread(target=_run, daemon=True, name="feishu-ws")
thread.start()
logger.info("Feishu WebSocket thread started")