Browse Source

feat: Feishu (飞书) bot integration — webhook + agent bridge

- agentpaas/services/feishu.py: token管理, 消息发送/回复, 事件处理, PaaS runner
- agentpaas/api/v1/feishu.py: webhook端点, bind/status API
- Registered in app.py

Usage:
  1. Set FEISHU_AGENT_ID=ag_xxx
  2. Configure Feishu webhook URL: http://server:8000/api/v1/feishu/webhook
  3. Users chat with bot in Feishu → agent processes → reply

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kenny67nju 7 months ago
parent
commit
dfc8129a78
3 changed files with 348 additions and 1 deletions
  1. 2 1
      agentpaas/api/app.py
  2. 82 0
      agentpaas/api/v1/feishu.py
  3. 264 0
      agentpaas/services/feishu.py

+ 2 - 1
agentpaas/api/app.py

@@ -10,7 +10,7 @@ import time as _time
 from fastapi import FastAPI, Request
 from fastapi.middleware.cors import CORSMiddleware
 from agentpaas.config import settings
-from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status
+from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, feishu
 from agentpaas.observability.logging import logger
 
 app = FastAPI(
@@ -114,6 +114,7 @@ app.include_router(traces.router, prefix="/api/v1")
 app.include_router(metrics.router, prefix="/api/v1")
 app.include_router(discovery.router)
 app.include_router(status.router, prefix="/api/v1")
+app.include_router(feishu.router, prefix="/api/v1")
 
 @app.get("/health")
 async def health():

+ 82 - 0
agentpaas/api/v1/feishu.py

@@ -0,0 +1,82 @@
+"""
+api.v1.feishu — 飞书机器人 Webhook 端点
+
+飞书开放平台事件订阅 URL: http://your-server:8000/api/v1/feishu/webhook
+"""
+from __future__ import annotations
+
+import json
+import os
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse
+
+router = APIRouter(prefix="/feishu", tags=["feishu"])
+
+# Agent runner(延迟初始化)
+_runner = None
+
+
+def _get_runner():
+    global _runner
+    if _runner is None:
+        agent_id = os.environ.get("FEISHU_AGENT_ID", "")
+        if agent_id:
+            from agentpaas.services.feishu import make_paas_runner
+            _runner = make_paas_runner(agent_id)
+    return _runner
+
+
+@router.post("/webhook")
+async def feishu_webhook(request: Request):
+    """
+    飞书事件回调端点。
+
+    飞书开放平台配置:
+      请求地址: http://your-server:8000/api/v1/feishu/webhook
+      加密策略: 不加密(开发阶段)
+    """
+    try:
+        body = await request.json()
+    except Exception:
+        return JSONResponse({"code": -1, "msg": "invalid json"})
+
+    from agentpaas.services.feishu import handle_event
+
+    # 使用线程池执行 Agent(避免阻塞 event loop)
+    import asyncio
+    loop = asyncio.get_event_loop()
+    result = await loop.run_in_executor(None, handle_event, body, _get_runner())
+
+    return JSONResponse(result)
+
+
+@router.post("/bind")
+async def feishu_bind(request: Request):
+    """绑定 Agent 到飞书机器人。"""
+    global _runner
+    try:
+        data = await request.json()
+    except Exception:
+        return JSONResponse({"error": "invalid json"}, status_code=400)
+
+    agent_id = data.get("agent_id", "")
+    if not agent_id:
+        return JSONResponse({"error": "agent_id required"}, status_code=400)
+
+    from agentpaas.services.feishu import make_paas_runner
+    _runner = make_paas_runner(agent_id)
+    os.environ["FEISHU_AGENT_ID"] = agent_id
+
+    return {"status": "ok", "agent_id": agent_id, "message": f"飞书机器人已绑定 Agent {agent_id}"}
+
+
+@router.get("/status")
+async def feishu_status():
+    """查看飞书机器人状态。"""
+    from agentpaas.services.feishu import FEISHU_APP_ID, FEISHU_AGENT_ID
+    return {
+        "app_id": FEISHU_APP_ID,
+        "agent_id": FEISHU_AGENT_ID or os.environ.get("FEISHU_AGENT_ID", "(未绑定)"),
+        "webhook_url": "/api/v1/feishu/webhook",
+        "runner_active": _runner is not None,
+    }

+ 264 - 0
agentpaas/services/feishu.py

@@ -0,0 +1,264 @@
+"""
+agentpaas.services.feishu — 飞书机器人桥接
+
+飞书用户发消息 → 飞书 Event → PaaS Agent 处理 → 回复飞书
+
+用法:
+    # 1. 配置飞书 App
+    export FEISHU_APP_ID=cli_xxx
+    export FEISHU_APP_SECRET=xxx
+
+    # 2. 启动 PaaS(飞书 webhook 端点自动注册)
+    agentpaas serve --dev
+
+    # 3. 飞书开放平台配置事件订阅 URL:
+    #    http://your-server:8000/api/v1/feishu/webhook
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import time
+import urllib.request
+import urllib.error
+from typing import Any, Dict, Optional
+
+
+# ════════════════════════════════════════════════════════════
+# Config
+# ════════════════════════════════════════════════════════════
+
+FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "cli_a944a8f033119bc9")
+FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "lDkwzGG7j2iDUkcBdts9odhAHk6DRLrw")
+FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
+
+# 绑定的 Agent ID(通过 CLI 或环境变量设置)
+FEISHU_AGENT_ID = os.environ.get("FEISHU_AGENT_ID", "")
+
+
+# ════════════════════════════════════════════════════════════
+# Token 管理
+# ════════════════════════════════════════════════════════════
+
+_tenant_access_token = ""
+_token_expires_at = 0
+
+
+def get_tenant_access_token() -> str:
+    """获取飞书 tenant_access_token(2 小时有效,自动刷新)。"""
+    global _tenant_access_token, _token_expires_at
+
+    if _tenant_access_token and time.time() < _token_expires_at - 300:
+        return _tenant_access_token
+
+    url = f"{FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal"
+    body = json.dumps({
+        "app_id": FEISHU_APP_ID,
+        "app_secret": FEISHU_APP_SECRET,
+    }).encode()
+
+    req = urllib.request.Request(url, data=body, headers={
+        "Content-Type": "application/json",
+    })
+
+    try:
+        with urllib.request.urlopen(req, timeout=10) as resp:
+            data = json.loads(resp.read().decode())
+        if data.get("code") == 0:
+            _tenant_access_token = data["tenant_access_token"]
+            _token_expires_at = time.time() + data.get("expire", 7200)
+            return _tenant_access_token
+        else:
+            raise RuntimeError(f"飞书 token 获取失败: {data}")
+    except Exception as e:
+        raise RuntimeError(f"飞书 token 请求失败: {e}")
+
+
+# ════════════════════════════════════════════════════════════
+# 发送消息
+# ════════════════════════════════════════════════════════════
+
+def send_message(chat_id: str, text: str, msg_type: str = "text") -> dict:
+    """向飞书聊天发送消息。"""
+    token = get_tenant_access_token()
+    url = f"{FEISHU_BASE_URL}/im/v1/messages?receive_id_type=chat_id"
+
+    if msg_type == "text":
+        content = json.dumps({"text": text})
+    else:
+        content = text
+
+    body = json.dumps({
+        "receive_id": chat_id,
+        "msg_type": msg_type,
+        "content": content,
+    }).encode()
+
+    req = urllib.request.Request(url, data=body, headers={
+        "Content-Type": "application/json",
+        "Authorization": f"Bearer {token}",
+    })
+
+    try:
+        with urllib.request.urlopen(req, timeout=30) as resp:
+            return json.loads(resp.read().decode())
+    except urllib.error.HTTPError as e:
+        error_body = e.read().decode()
+        return {"error": error_body, "code": e.code}
+    except Exception as e:
+        return {"error": str(e)}
+
+
+def reply_message(message_id: str, text: str) -> dict:
+    """回复飞书消息。"""
+    token = get_tenant_access_token()
+    url = f"{FEISHU_BASE_URL}/im/v1/messages/{message_id}/reply"
+
+    body = json.dumps({
+        "msg_type": "text",
+        "content": json.dumps({"text": text}),
+    }).encode()
+
+    req = urllib.request.Request(url, data=body, headers={
+        "Content-Type": "application/json",
+        "Authorization": f"Bearer {token}",
+    })
+
+    try:
+        with urllib.request.urlopen(req, timeout=30) as resp:
+            return json.loads(resp.read().decode())
+    except urllib.error.HTTPError as e:
+        error_body = e.read().decode()
+        return {"error": error_body, "code": e.code}
+    except Exception as e:
+        return {"error": str(e)}
+
+
+# ════════════════════════════════════════════════════════════
+# 事件处理
+# ════════════════════════════════════════════════════════════
+
+# 消息去重(避免重复处理)
+_processed_events: set = set()
+_MAX_EVENTS = 1000
+
+
+def handle_event(event_data: dict, agent_runner=None) -> dict:
+    """
+    处理飞书事件回调。
+
+    event_data: 飞书推送的完整 JSON
+    agent_runner: callable(input_text) -> output_text(Agent 执行函数)
+    """
+    # URL 验证(飞书首次配置时发送)
+    if "challenge" in event_data:
+        return {"challenge": event_data["challenge"]}
+
+    # Schema 2.0 事件格式
+    header = event_data.get("header", {})
+    event = event_data.get("event", {})
+    event_id = header.get("event_id", "")
+    event_type = header.get("event_type", "")
+
+    # 去重
+    if event_id in _processed_events:
+        return {"code": 0, "msg": "duplicate"}
+    _processed_events.add(event_id)
+    if len(_processed_events) > _MAX_EVENTS:
+        # 清理旧事件
+        to_remove = list(_processed_events)[:_MAX_EVENTS // 2]
+        for e in to_remove:
+            _processed_events.discard(e)
+
+    # 消息事件
+    if event_type == "im.message.receive_v1":
+        return _handle_message(event, agent_runner)
+
+    return {"code": 0, "msg": f"unhandled event: {event_type}"}
+
+
+def _handle_message(event: dict, agent_runner=None) -> dict:
+    """处理收到的消息。"""
+    message = event.get("message", {})
+    msg_type = message.get("message_type", "")
+    message_id = message.get("message_id", "")
+    chat_id = message.get("chat_id", "")
+
+    # 只处理文本消息
+    if msg_type != "text":
+        reply_message(message_id, "🐑 lambda 目前只支持文本消息哦")
+        return {"code": 0}
+
+    # 提取文本内容
+    try:
+        content = json.loads(message.get("content", "{}"))
+        text = content.get("text", "").strip()
+    except (json.JSONDecodeError, Exception):
+        text = ""
+
+    if not text:
+        return {"code": 0}
+
+    # 去掉 @机器人 的前缀
+    # 飞书 @bot 格式: @_user_1 text
+    import re
+    text = re.sub(r'@_user_\d+\s*', '', text).strip()
+
+    if not text:
+        return {"code": 0}
+
+    # 调用 Agent
+    if agent_runner:
+        try:
+            # 先发一个"思考中"的提示
+            reply_message(message_id, "🐑 让我看看...")
+
+            # 执行 Agent
+            output = agent_runner(text)
+
+            # 截断过长的输出(飞书消息限制)
+            if len(output) > 4000:
+                output = output[:4000] + "\n\n... [内容过长,已截断]"
+
+            # 发送结果
+            send_message(chat_id, f"🐑 lambda:\n\n{output}")
+        except Exception as e:
+            send_message(chat_id, f"🐑 出错了: {e}")
+    else:
+        reply_message(message_id, "🐑 Agent 未绑定,请先运行: agentpaas feishu bind <agent_id>")
+
+    return {"code": 0}
+
+
+# ════════════════════════════════════════════════════════════
+# Agent Runner (通过 PaaS API 调用)
+# ════════════════════════════════════════════════════════════
+
+def make_paas_runner(agent_id: str, api_key: str = "", server: str = "http://localhost:8000"):
+    """创建通过 PaaS API 调用 Agent 的 runner。"""
+    if not api_key:
+        config_path = os.path.expanduser("~/.agentpaas/config.json")
+        if os.path.exists(config_path):
+            with open(config_path) as f:
+                cfg = json.load(f)
+            api_key = cfg.get("api_key", "")
+            server = cfg.get("server", server)
+
+    def runner(input_text: str) -> str:
+        url = f"{server}/api/v1/agents/{agent_id}/run"
+        body = json.dumps({"input": input_text}).encode()
+        req = urllib.request.Request(url, data=body, headers={
+            "Content-Type": "application/json",
+            "Authorization": f"Bearer {api_key}",
+        })
+        try:
+            with urllib.request.urlopen(req, timeout=300) as resp:
+                data = json.loads(resp.read().decode())
+            return data.get("output", str(data))
+        except urllib.error.HTTPError as e:
+            return f"[PaaS Error {e.code}] {e.read().decode()[:200]}"
+        except Exception as e:
+            return f"[Error] {e}"
+
+    return runner