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:
commit
0eb29f2dcc
72
.gitignore
vendored
Normal file
72
.gitignore
vendored
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environment variables and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
keyring.yaml
|
||||||
|
secrets.yaml
|
||||||
|
secrets.json
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# Claude settings
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# MyPy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Ruff
|
||||||
|
.ruff_cache/
|
||||||
48
README.md
Normal file
48
README.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# PhoneWork
|
||||||
|
|
||||||
|
Feishu bot integration with Claude Code CLI.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Agent CLI**: Claude Code (print mode)
|
||||||
|
- **Chat Server**: FastAPI
|
||||||
|
- **Client**: Feishu bot API (long-connection)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Feishu App
|
||||||
|
Create app at https://open.feishu.cn:
|
||||||
|
- Enable **Bot** capability
|
||||||
|
- Enable **long-connection event subscription** (no public URL needed)
|
||||||
|
- Get `FEISHU_APP_ID` and `FEISHU_APP_SECRET`
|
||||||
|
|
||||||
|
### 2. LLM Endpoint
|
||||||
|
Configure OpenAI-compatible endpoint:
|
||||||
|
- `OPENAI_BASE_URL`
|
||||||
|
- `OPENAI_API_KEY`
|
||||||
|
- `OPENAI_MODEL`
|
||||||
|
|
||||||
|
### 3. Claude Code CLI
|
||||||
|
- Install and authenticate `claude` command
|
||||||
|
- Ensure available in PATH
|
||||||
|
|
||||||
|
### 4. Configuration
|
||||||
|
```bash
|
||||||
|
cp keyring.example.yaml keyring.yaml
|
||||||
|
# Edit keyring.yaml with your credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Run
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Item | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Python 3.11+ | Required |
|
||||||
|
| Feishu App | Bot + long-connection enabled |
|
||||||
|
| OpenAI-compatible LLM | API endpoint and key |
|
||||||
|
| Claude Code CLI | Installed + authenticated |
|
||||||
1
agent/__init__.py
Normal file
1
agent/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# empty init
|
||||||
128
agent/manager.py
Normal file
128
agent/manager.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"""Session registry: tracks active project sessions by conversation ID."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from agent.pty_process import run_claude
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IDLE_TIMEOUT = 30 * 60 # 30 minutes in seconds
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Session:
|
||||||
|
conv_id: str
|
||||||
|
cwd: str
|
||||||
|
# Stable UUID passed to `claude --session-id` so CC owns the history
|
||||||
|
cc_session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
last_activity: float = field(default_factory=lambda: asyncio.get_event_loop().time())
|
||||||
|
# True after the first message has been sent (so we know to use --resume)
|
||||||
|
started: bool = False
|
||||||
|
|
||||||
|
def touch(self) -> None:
|
||||||
|
self.last_activity = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""Registry of active Claude Code project sessions."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._sessions: Dict[str, Session] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._reaper_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self._reaper_task = loop.create_task(self._reaper_loop())
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._reaper_task:
|
||||||
|
self._reaper_task.cancel()
|
||||||
|
async with self._lock:
|
||||||
|
self._sessions.clear()
|
||||||
|
|
||||||
|
async def create(self, conv_id: str, working_dir: str) -> Session:
|
||||||
|
"""Register a new session for the given working directory."""
|
||||||
|
async with self._lock:
|
||||||
|
session = Session(conv_id=conv_id, cwd=working_dir)
|
||||||
|
self._sessions[conv_id] = session
|
||||||
|
logger.info(
|
||||||
|
"Created session %s (cc_session_id=%s) in %s",
|
||||||
|
conv_id, session.cc_session_id, working_dir,
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def send(self, conv_id: str, message: str) -> str:
|
||||||
|
"""
|
||||||
|
Run claude -p with the message in the session's directory.
|
||||||
|
|
||||||
|
- First message: uses --session-id <uuid> to establish the CC session.
|
||||||
|
- Subsequent messages: uses --resume <uuid> so CC has full history.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
session = self._sessions.get(conv_id)
|
||||||
|
if session is None:
|
||||||
|
raise KeyError(f"No session for conv_id={conv_id!r}")
|
||||||
|
session.touch()
|
||||||
|
cwd = session.cwd
|
||||||
|
cc_session_id = session.cc_session_id
|
||||||
|
first_message = not session.started
|
||||||
|
if first_message:
|
||||||
|
session.started = True
|
||||||
|
|
||||||
|
output = await run_claude(
|
||||||
|
message,
|
||||||
|
cwd=cwd,
|
||||||
|
cc_session_id=cc_session_id,
|
||||||
|
resume=not first_message,
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
async def close(self, conv_id: str) -> bool:
|
||||||
|
"""Remove a session. Returns True if it existed."""
|
||||||
|
async with self._lock:
|
||||||
|
if conv_id not in self._sessions:
|
||||||
|
return False
|
||||||
|
del self._sessions[conv_id]
|
||||||
|
logger.info("Closed session %s", conv_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_sessions(self) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"conv_id": s.conv_id, "cwd": s.cwd, "cc_session_id": s.cc_session_id}
|
||||||
|
for s in self._sessions.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _reaper_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
await self._reap_idle()
|
||||||
|
|
||||||
|
async def _reap_idle(self) -> None:
|
||||||
|
now = asyncio.get_event_loop().time()
|
||||||
|
async with self._lock:
|
||||||
|
to_close = [
|
||||||
|
cid for cid, s in self._sessions.items()
|
||||||
|
if (now - s.last_activity) > IDLE_TIMEOUT
|
||||||
|
]
|
||||||
|
for cid in to_close:
|
||||||
|
del self._sessions[cid]
|
||||||
|
logger.info("Reaped idle session %s", cid)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton
|
||||||
|
manager = SessionManager()
|
||||||
86
agent/pty_process.py
Normal file
86
agent/pty_process.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""Headless Claude Code runner using `claude -p` (print/non-interactive mode)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||||
|
MAX_OUTPUT_CHARS = 8000 # truncate very long responses before sending to Feishu
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ansi(text: str) -> str:
|
||||||
|
return ANSI_ESCAPE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_claude(
|
||||||
|
prompt: str,
|
||||||
|
cwd: str,
|
||||||
|
cc_session_id: str | None = None,
|
||||||
|
resume: bool = False,
|
||||||
|
timeout: float = 300.0,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Run `claude -p <prompt>` in the given directory and return the output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: The message/instruction to pass to Claude Code.
|
||||||
|
cwd: Working directory for the subprocess.
|
||||||
|
cc_session_id: Stable UUID for the Claude Code session.
|
||||||
|
- First call: passed as --session-id to establish the session.
|
||||||
|
- Subsequent calls: passed as --resume so CC has full history.
|
||||||
|
resume: If True, use --resume instead of --session-id.
|
||||||
|
timeout: Maximum seconds to wait before giving up.
|
||||||
|
"""
|
||||||
|
base_args = [
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"-p", prompt,
|
||||||
|
]
|
||||||
|
|
||||||
|
if cc_session_id:
|
||||||
|
if resume:
|
||||||
|
base_args = ["--resume", cc_session_id] + base_args
|
||||||
|
else:
|
||||||
|
base_args = ["--session-id", cc_session_id] + base_args
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# `claude` is a .cmd shim on Windows — must go through cmd /c
|
||||||
|
args = ["cmd", "/c", "claude"] + base_args
|
||||||
|
else:
|
||||||
|
args = ["claude"] + base_args
|
||||||
|
|
||||||
|
logger.debug("Running: %s (cwd=%s)", " ".join(args[:6]), cwd)
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||||
|
proc.communicate(), timeout=timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.communicate()
|
||||||
|
return f"[Timed out after {timeout:.0f}s]"
|
||||||
|
|
||||||
|
output = stdout_bytes.decode("utf-8", errors="replace")
|
||||||
|
output = strip_ansi(output).strip()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||||||
|
logger.warning("claude -p exited %d: %s", proc.returncode, err[:200])
|
||||||
|
if not output:
|
||||||
|
output = f"[Error exit {proc.returncode}] {err[:500]}"
|
||||||
|
|
||||||
|
if len(output) > MAX_OUTPUT_CHARS:
|
||||||
|
output = output[:MAX_OUTPUT_CHARS] + "\n...[truncated]"
|
||||||
|
|
||||||
|
return output
|
||||||
1
bot/__init__.py
Normal file
1
bot/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# empty init
|
||||||
79
bot/feishu.py
Normal file
79
bot/feishu.py
Normal 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
bot/handler.py
Normal file
130
bot/handler.py
Normal 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")
|
||||||
19
config.py
Normal file
19
config.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_CONFIG_PATH = Path(__file__).parent / "keyring.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict:
|
||||||
|
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
|
||||||
|
_cfg = _load()
|
||||||
|
|
||||||
|
FEISHU_APP_ID: str = _cfg["FEISHU_APP_ID"]
|
||||||
|
FEISHU_APP_SECRET: str = _cfg["FEISHU_APP_SECRET"]
|
||||||
|
OPENAI_BASE_URL: str = _cfg["OPENAI_BASE_URL"]
|
||||||
|
OPENAI_API_KEY: str = _cfg["OPENAI_API_KEY"]
|
||||||
|
OPENAI_MODEL: str = _cfg.get("OPENAI_MODEL", "glm-4.7")
|
||||||
|
WORKING_DIR: Path = Path(_cfg.get("WORKING_DIR", Path.home())).expanduser().resolve()
|
||||||
31
debug_test.py
Normal file
31
debug_test.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""Quick diagnostic script — run interactively to trace what's failing."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Step 1: Can the agent call tools at all?
|
||||||
|
print("\n=== Step 1: Agent tool-calling test ===")
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
reply = await agent.run(
|
||||||
|
"test_user",
|
||||||
|
"请在 C:\\Users\\hyuyao\\Documents\\PhoneWork 目录开一个新的 Claude Code 会话,conv_id 随便取。"
|
||||||
|
)
|
||||||
|
print("Agent reply:", reply)
|
||||||
|
|
||||||
|
# Step 2: Check manager sessions
|
||||||
|
print("\n=== Step 2: Active sessions ===")
|
||||||
|
from agent.manager import manager
|
||||||
|
print(manager.list_sessions())
|
||||||
|
|
||||||
|
# Step 3: If session exists, try sending a message
|
||||||
|
sessions = manager.list_sessions()
|
||||||
|
if sessions:
|
||||||
|
cid = sessions[0]["conv_id"]
|
||||||
|
print(f"\n=== Step 3: Send 'hello' to session {cid} ===")
|
||||||
|
out = await manager.send(cid, "hello")
|
||||||
|
print("Output:", out[:500])
|
||||||
|
else:
|
||||||
|
print("No sessions — tool was not called or failed.")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
6
keyring.example.yaml
Normal file
6
keyring.example.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
FEISHU_APP_ID: your_feishu_app_id
|
||||||
|
FEISHU_APP_SECRET: your_feishu_app_secret
|
||||||
|
OPENAI_BASE_URL: https://api.openai.com/v1/
|
||||||
|
OPENAI_API_KEY: your_openai_api_key
|
||||||
|
OPENAI_MODEL: gpt-4
|
||||||
|
WORKING_DIR: "/path/to/working/directory"
|
||||||
59
main.py
Normal file
59
main.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""PhoneWork entry point: FastAPI app + Feishu long-connection client."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from agent.manager import manager
|
||||||
|
from bot.handler import start_websocket_client
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="PhoneWork", version="0.1.0")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
sessions = manager.list_sessions()
|
||||||
|
return {"status": "ok", "active_sessions": len(sessions)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sessions")
|
||||||
|
async def list_sessions() -> list:
|
||||||
|
return manager.list_sessions()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event() -> None:
|
||||||
|
# Start the session manager's idle-reaper
|
||||||
|
await manager.start()
|
||||||
|
|
||||||
|
# Start the Feishu WebSocket client in a background thread,
|
||||||
|
# passing the running event loop so async work can be scheduled
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
start_websocket_client(loop)
|
||||||
|
logger.info("PhoneWork started")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event() -> None:
|
||||||
|
await manager.stop()
|
||||||
|
logger.info("PhoneWork shut down")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=False,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
1
orchestrator/__init__.py
Normal file
1
orchestrator/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# empty init
|
||||||
150
orchestrator/agent.py
Normal file
150
orchestrator/agent.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
"""LangChain orchestration agent backed by ZhipuAI (OpenAI-compatible API).
|
||||||
|
|
||||||
|
Uses LangChain 1.x tool-calling pattern: bind_tools + manual agentic loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from langchain_core.messages import (
|
||||||
|
AIMessage,
|
||||||
|
BaseMessage,
|
||||||
|
HumanMessage,
|
||||||
|
SystemMessage,
|
||||||
|
ToolMessage,
|
||||||
|
)
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
|
from config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, WORKING_DIR
|
||||||
|
from orchestrator.tools import TOOLS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SYSTEM_PROMPT_TEMPLATE = """You are PhoneWork, an AI assistant that helps users control Claude Code \
|
||||||
|
from their phone via Feishu (飞书).
|
||||||
|
|
||||||
|
You manage Claude Code sessions. Each session has a conv_id and runs in a project directory.
|
||||||
|
|
||||||
|
Base working directory: {working_dir}
|
||||||
|
Users refer to projects by subfolder name (e.g. "todo_app") or relative path. \
|
||||||
|
Pass these names directly to `create_conversation` — the tool resolves them automatically.
|
||||||
|
|
||||||
|
{active_session_line}
|
||||||
|
|
||||||
|
Your responsibilities:
|
||||||
|
1. NEW session: call `create_conversation` with the project name/path. \
|
||||||
|
If the user's message also contains a task, pass it as `initial_message` too.
|
||||||
|
2. Follow-up to ACTIVE session: call `send_to_conversation` with the active conv_id shown above.
|
||||||
|
3. List sessions: call `list_conversations`.
|
||||||
|
4. Close session: call `close_conversation`.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Relay Claude Code's output verbatim.
|
||||||
|
- If no active session and the user sends a task without naming a directory, ask them which project.
|
||||||
|
- Keep your own words brief — let Claude Code's output speak.
|
||||||
|
- Reply in the same language the user uses (Chinese or English).
|
||||||
|
"""
|
||||||
|
|
||||||
|
MAX_ITERATIONS = 10
|
||||||
|
_TOOL_MAP = {t.name: t for t in TOOLS}
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestrationAgent:
|
||||||
|
"""Per-user agent with conversation history and active session tracking."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
llm = ChatOpenAI(
|
||||||
|
base_url=OPENAI_BASE_URL,
|
||||||
|
api_key=OPENAI_API_KEY,
|
||||||
|
model=OPENAI_MODEL,
|
||||||
|
temperature=0.0,
|
||||||
|
)
|
||||||
|
self._llm_with_tools = llm.bind_tools(TOOLS)
|
||||||
|
|
||||||
|
# user_id -> list[BaseMessage]
|
||||||
|
self._history: Dict[str, List[BaseMessage]] = defaultdict(list)
|
||||||
|
# user_id -> most recently active conv_id
|
||||||
|
self._active_conv: Dict[str, Optional[str]] = defaultdict(lambda: None)
|
||||||
|
|
||||||
|
def _build_system_prompt(self, user_id: str) -> str:
|
||||||
|
conv_id = self._active_conv[user_id]
|
||||||
|
if conv_id:
|
||||||
|
active_line = f"ACTIVE SESSION: conv_id={conv_id!r} ← use this for all follow-up messages"
|
||||||
|
else:
|
||||||
|
active_line = "ACTIVE SESSION: none"
|
||||||
|
return SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
|
working_dir=WORKING_DIR,
|
||||||
|
active_session_line=active_line,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(self, user_id: str, text: str) -> str:
|
||||||
|
"""Process a user message and return the agent's reply."""
|
||||||
|
messages: List[BaseMessage] = (
|
||||||
|
[SystemMessage(content=self._build_system_prompt(user_id))]
|
||||||
|
+ self._history[user_id]
|
||||||
|
+ [HumanMessage(content=text)]
|
||||||
|
)
|
||||||
|
|
||||||
|
reply = ""
|
||||||
|
try:
|
||||||
|
for _ in range(MAX_ITERATIONS):
|
||||||
|
ai_msg: AIMessage = await self._llm_with_tools.ainvoke(messages)
|
||||||
|
messages.append(ai_msg)
|
||||||
|
|
||||||
|
if not ai_msg.tool_calls:
|
||||||
|
reply = ai_msg.content or ""
|
||||||
|
break
|
||||||
|
|
||||||
|
for tc in ai_msg.tool_calls:
|
||||||
|
tool_name = tc["name"]
|
||||||
|
tool_args = tc["args"]
|
||||||
|
tool_id = tc["id"]
|
||||||
|
|
||||||
|
tool_obj = _TOOL_MAP.get(tool_name)
|
||||||
|
if tool_obj is None:
|
||||||
|
result = f"Unknown tool: {tool_name}"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = await tool_obj.arun(tool_args)
|
||||||
|
except Exception as exc:
|
||||||
|
result = f"Tool error: {exc}"
|
||||||
|
|
||||||
|
# If a session was just created, record it as the active session
|
||||||
|
if tool_name == "create_conversation":
|
||||||
|
try:
|
||||||
|
data = json.loads(result)
|
||||||
|
if "conv_id" in data:
|
||||||
|
self._active_conv[user_id] = data["conv_id"]
|
||||||
|
logger.info(
|
||||||
|
"Active session for %s set to %s",
|
||||||
|
user_id, data["conv_id"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
messages.append(
|
||||||
|
ToolMessage(content=str(result), tool_call_id=tool_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reply = "[Max iterations reached]"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Agent error for user %s", user_id)
|
||||||
|
reply = f"[Error] {exc}"
|
||||||
|
|
||||||
|
# Update history
|
||||||
|
self._history[user_id].append(HumanMessage(content=text))
|
||||||
|
self._history[user_id].append(AIMessage(content=reply))
|
||||||
|
|
||||||
|
if len(self._history[user_id]) > 40:
|
||||||
|
self._history[user_id] = self._history[user_id][-40:]
|
||||||
|
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton
|
||||||
|
agent = OrchestrationAgent()
|
||||||
162
orchestrator/tools.py
Normal file
162
orchestrator/tools.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
"""LangChain tools that bridge the orchestration agent to Claude Code PTY sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from langchain_core.tools import BaseTool
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from agent.manager import manager
|
||||||
|
from config import WORKING_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_dir(working_dir: str) -> Path:
|
||||||
|
"""
|
||||||
|
Resolve working_dir to an absolute path under WORKING_DIR.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Absolute paths are used as-is (but must stay within WORKING_DIR for safety).
|
||||||
|
- Relative paths / bare names are joined onto WORKING_DIR.
|
||||||
|
- The resolved directory is created if it doesn't exist.
|
||||||
|
"""
|
||||||
|
p = Path(working_dir)
|
||||||
|
if not p.is_absolute():
|
||||||
|
p = WORKING_DIR / p
|
||||||
|
p = p.resolve()
|
||||||
|
|
||||||
|
# Safety: must be inside WORKING_DIR
|
||||||
|
try:
|
||||||
|
p.relative_to(WORKING_DIR)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"Directory {p} is outside the allowed base {WORKING_DIR}. "
|
||||||
|
"Please use a subfolder name or a path inside the working directory."
|
||||||
|
)
|
||||||
|
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CreateConversationInput(BaseModel):
|
||||||
|
working_dir: str = Field(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
"The project directory. Can be a subfolder name (e.g. 'todo_app'), "
|
||||||
|
"a relative path (e.g. 'projects/todo_app'), or a full absolute path. "
|
||||||
|
"Relative names are resolved under the configured base working directory."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
initial_message: Optional[str] = Field(None, description="Optional first message to send after spawning")
|
||||||
|
|
||||||
|
|
||||||
|
class SendToConversationInput(BaseModel):
|
||||||
|
conv_id: str = Field(..., description="Conversation ID returned by create_conversation")
|
||||||
|
message: str = Field(..., description="Message / instruction to send to Claude Code")
|
||||||
|
|
||||||
|
|
||||||
|
class CloseConversationInput(BaseModel):
|
||||||
|
conv_id: str = Field(..., description="Conversation ID to close")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tools
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CreateConversationTool(BaseTool):
|
||||||
|
name: str = "create_conversation"
|
||||||
|
description: str = (
|
||||||
|
"Spawn a new Claude Code session in the given working directory. "
|
||||||
|
"Returns a conv_id that must be used for subsequent messages. "
|
||||||
|
"Use this when the user wants to start a new task in a specific directory."
|
||||||
|
)
|
||||||
|
args_schema: Type[BaseModel] = CreateConversationInput
|
||||||
|
|
||||||
|
def _run(self, working_dir: str, initial_message: Optional[str] = None) -> str:
|
||||||
|
raise NotImplementedError("Use async version")
|
||||||
|
|
||||||
|
async def _arun(self, working_dir: str, initial_message: Optional[str] = None) -> str:
|
||||||
|
try:
|
||||||
|
resolved = _resolve_dir(working_dir)
|
||||||
|
except ValueError as exc:
|
||||||
|
return json.dumps({"error": str(exc)})
|
||||||
|
|
||||||
|
conv_id = str(uuid.uuid4())[:8]
|
||||||
|
await manager.create(conv_id, str(resolved))
|
||||||
|
|
||||||
|
result: dict = {
|
||||||
|
"conv_id": conv_id,
|
||||||
|
"working_dir": str(resolved),
|
||||||
|
}
|
||||||
|
|
||||||
|
if initial_message:
|
||||||
|
output = await manager.send(conv_id, initial_message)
|
||||||
|
result["response"] = output
|
||||||
|
else:
|
||||||
|
result["status"] = "Session created. Send a message to start working."
|
||||||
|
|
||||||
|
return json.dumps(result, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SendToConversationTool(BaseTool):
|
||||||
|
name: str = "send_to_conversation"
|
||||||
|
description: str = (
|
||||||
|
"Send a message to an existing Claude Code session and return its response. "
|
||||||
|
"Use this for follow-up messages in an ongoing session."
|
||||||
|
)
|
||||||
|
args_schema: Type[BaseModel] = SendToConversationInput
|
||||||
|
|
||||||
|
def _run(self, conv_id: str, message: str) -> str:
|
||||||
|
raise NotImplementedError("Use async version")
|
||||||
|
|
||||||
|
async def _arun(self, conv_id: str, message: str) -> str:
|
||||||
|
try:
|
||||||
|
output = await manager.send(conv_id, message)
|
||||||
|
return json.dumps({"conv_id": conv_id, "response": output}, ensure_ascii=False)
|
||||||
|
except KeyError:
|
||||||
|
return json.dumps({"error": f"No active session for conv_id={conv_id!r}"})
|
||||||
|
|
||||||
|
|
||||||
|
class ListConversationsTool(BaseTool):
|
||||||
|
name: str = "list_conversations"
|
||||||
|
description: str = "List all currently active Claude Code sessions."
|
||||||
|
|
||||||
|
def _run(self) -> str:
|
||||||
|
raise NotImplementedError("Use async version")
|
||||||
|
|
||||||
|
async def _arun(self) -> str:
|
||||||
|
sessions = manager.list_sessions()
|
||||||
|
if not sessions:
|
||||||
|
return "No active sessions."
|
||||||
|
return json.dumps(sessions, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
class CloseConversationTool(BaseTool):
|
||||||
|
name: str = "close_conversation"
|
||||||
|
description: str = "Close and terminate an active Claude Code session."
|
||||||
|
args_schema: Type[BaseModel] = CloseConversationInput
|
||||||
|
|
||||||
|
def _run(self, conv_id: str) -> str:
|
||||||
|
raise NotImplementedError("Use async version")
|
||||||
|
|
||||||
|
async def _arun(self, conv_id: str) -> str:
|
||||||
|
closed = await manager.close(conv_id)
|
||||||
|
if closed:
|
||||||
|
return f"Session {conv_id!r} closed."
|
||||||
|
return f"Session {conv_id!r} not found."
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level tool list for easy import
|
||||||
|
TOOLS = [
|
||||||
|
CreateConversationTool(),
|
||||||
|
SendToConversationTool(),
|
||||||
|
ListConversationsTool(),
|
||||||
|
CloseConversationTool(),
|
||||||
|
]
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn>=0.29.0
|
||||||
|
lark-oapi>=1.3.0
|
||||||
|
langchain>=0.2.0
|
||||||
|
langchain-openai>=0.1.0
|
||||||
|
langchain-community>=0.2.0
|
||||||
|
pywinpty>=2.0.0
|
||||||
|
pyyaml>=6.0.0
|
||||||
Loading…
x
Reference in New Issue
Block a user