|
|
@@ -1,408 +0,0 @@
|
|
|
-"""
|
|
|
-agentpaas.services.feishu — 飞书机器人桥接 (参考 openclaw-lark 架构)
|
|
|
-
|
|
|
-核心能力(参考 @larksuite/openclaw-lark):
|
|
|
- - 流式卡片回复 (CardKit 2.0 streaming)
|
|
|
- - 消息去重 + 事件分发
|
|
|
- - 群策略 (requireMention / allowlist)
|
|
|
- - 多种回复模式 (text / card / streaming card)
|
|
|
- - Token 自动刷新
|
|
|
-
|
|
|
-架构:
|
|
|
- 飞书 Event → webhook endpoint → handle_event()
|
|
|
- → resolve_message() → run_agent() → reply()
|
|
|
-"""
|
|
|
-from __future__ import annotations
|
|
|
-
|
|
|
-import hashlib
|
|
|
-import json
|
|
|
-import os
|
|
|
-import re
|
|
|
-import time
|
|
|
-import uuid
|
|
|
-import urllib.request
|
|
|
-import urllib.error
|
|
|
-from typing import Any, Callable, Dict, List, Optional
|
|
|
-
|
|
|
-
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-# Config
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-
|
|
|
-FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "")
|
|
|
-FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
|
|
|
-FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
|
|
|
-FEISHU_AGENT_ID = os.environ.get("FEISHU_AGENT_ID", "")
|
|
|
-
|
|
|
-# 群策略
|
|
|
-REQUIRE_MENTION = True # 群聊中需要 @机器人 才响应
|
|
|
-GROUP_POLICY = "open" # open / allowlist
|
|
|
-
|
|
|
-
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-# Token 管理 (参考 lark-client.js)
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-
|
|
|
-_tenant_token = ""
|
|
|
-_token_expires = 0
|
|
|
-
|
|
|
-
|
|
|
-def _get_token() -> str:
|
|
|
- global _tenant_token, _token_expires
|
|
|
- if _tenant_token and time.time() < _token_expires - 300:
|
|
|
- return _tenant_token
|
|
|
-
|
|
|
- data = _feishu_post("/auth/v3/tenant_access_token/internal", {
|
|
|
- "app_id": FEISHU_APP_ID,
|
|
|
- "app_secret": FEISHU_APP_SECRET,
|
|
|
- }, auth=False)
|
|
|
-
|
|
|
- if data.get("code") == 0:
|
|
|
- _tenant_token = data["tenant_access_token"]
|
|
|
- _token_expires = time.time() + data.get("expire", 7200)
|
|
|
- return _tenant_token
|
|
|
- raise RuntimeError(f"飞书 token 失败: {data}")
|
|
|
-
|
|
|
-
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-# HTTP 层 (统一请求)
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-
|
|
|
-def _feishu_post(path: str, body: dict, auth: bool = True) -> dict:
|
|
|
- url = f"{FEISHU_BASE_URL}{path}"
|
|
|
- headers = {"Content-Type": "application/json"}
|
|
|
- if auth:
|
|
|
- headers["Authorization"] = f"Bearer {_get_token()}"
|
|
|
-
|
|
|
- req = urllib.request.Request(
|
|
|
- url, data=json.dumps(body).encode(), headers=headers, method="POST"
|
|
|
- )
|
|
|
- try:
|
|
|
- with urllib.request.urlopen(req, timeout=30) as resp:
|
|
|
- return json.loads(resp.read().decode())
|
|
|
- except urllib.error.HTTPError as e:
|
|
|
- return {"error": e.read().decode(), "code": e.code}
|
|
|
- except Exception as e:
|
|
|
- return {"error": str(e)}
|
|
|
-
|
|
|
-
|
|
|
-def _feishu_get(path: str, params: dict = None) -> dict:
|
|
|
- url = f"{FEISHU_BASE_URL}{path}"
|
|
|
- if params:
|
|
|
- qs = "&".join(f"{k}={v}" for k, v in params.items())
|
|
|
- url += f"?{qs}"
|
|
|
- headers = {"Authorization": f"Bearer {_get_token()}"}
|
|
|
- req = urllib.request.Request(url, headers=headers)
|
|
|
- try:
|
|
|
- with urllib.request.urlopen(req, timeout=15) as resp:
|
|
|
- return json.loads(resp.read().decode())
|
|
|
- except Exception as e:
|
|
|
- return {"error": str(e)}
|
|
|
-
|
|
|
-
|
|
|
-def _feishu_patch(path: str, body: dict) -> dict:
|
|
|
- url = f"{FEISHU_BASE_URL}{path}"
|
|
|
- headers = {
|
|
|
- "Content-Type": "application/json",
|
|
|
- "Authorization": f"Bearer {_get_token()}",
|
|
|
- }
|
|
|
- req = urllib.request.Request(
|
|
|
- url, data=json.dumps(body).encode(), headers=headers, method="PATCH"
|
|
|
- )
|
|
|
- try:
|
|
|
- with urllib.request.urlopen(req, timeout=15) as resp:
|
|
|
- return json.loads(resp.read().decode())
|
|
|
- except Exception as e:
|
|
|
- return {"error": str(e)}
|
|
|
-
|
|
|
-
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-# 消息发送 (参考 messaging/outbound/send.js)
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-
|
|
|
-def send_text(chat_id: str, text: str) -> dict:
|
|
|
- """发送纯文本消息。"""
|
|
|
- return _feishu_post("/im/v1/messages?receive_id_type=chat_id", {
|
|
|
- "receive_id": chat_id,
|
|
|
- "msg_type": "text",
|
|
|
- "content": json.dumps({"text": text}),
|
|
|
- })
|
|
|
-
|
|
|
-
|
|
|
-def reply_text(message_id: str, text: str) -> dict:
|
|
|
- """回复消息(纯文本)。"""
|
|
|
- return _feishu_post(f"/im/v1/messages/{message_id}/reply", {
|
|
|
- "msg_type": "text",
|
|
|
- "content": json.dumps({"text": text}),
|
|
|
- })
|
|
|
-
|
|
|
-
|
|
|
-def send_card(chat_id: str, card: dict) -> dict:
|
|
|
- """发送交互卡片。"""
|
|
|
- return _feishu_post("/im/v1/messages?receive_id_type=chat_id", {
|
|
|
- "receive_id": chat_id,
|
|
|
- "msg_type": "interactive",
|
|
|
- "content": json.dumps(card),
|
|
|
- })
|
|
|
-
|
|
|
-
|
|
|
-def reply_card(message_id: str, card: dict) -> dict:
|
|
|
- """用卡片回复消息。"""
|
|
|
- return _feishu_post(f"/im/v1/messages/{message_id}/reply", {
|
|
|
- "msg_type": "interactive",
|
|
|
- "content": json.dumps(card),
|
|
|
- })
|
|
|
-
|
|
|
-
|
|
|
-def update_card(message_id: str, card: dict) -> dict:
|
|
|
- """更新已发送的卡片(用于流式更新)。"""
|
|
|
- return _feishu_patch(f"/im/v1/messages/{message_id}", {
|
|
|
- "msg_type": "interactive",
|
|
|
- "content": json.dumps(card),
|
|
|
- })
|
|
|
-
|
|
|
-
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-# 卡片构建 (参考 card/builder.js)
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-
|
|
|
-def build_thinking_card() -> dict:
|
|
|
- """构建"思考中"卡片。"""
|
|
|
- return {
|
|
|
- "header": {
|
|
|
- "title": {"tag": "plain_text", "content": "🐂 lambda"},
|
|
|
- "template": "blue",
|
|
|
- },
|
|
|
- "elements": [
|
|
|
- {"tag": "div", "text": {"tag": "lark_md", "content": "⏳ 让我看看..."}},
|
|
|
- ],
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-def build_result_card(content: str, elapsed: float = 0, steps: int = 0,
|
|
|
- tokens: int = 0) -> dict:
|
|
|
- """构建结果卡片(Markdown 格式)。"""
|
|
|
- # 飞书卡片 Markdown 有限制,做基本清理
|
|
|
- content = _clean_for_feishu(content)
|
|
|
-
|
|
|
- elements = [
|
|
|
- {"tag": "div", "text": {"tag": "lark_md", "content": content}},
|
|
|
- ]
|
|
|
-
|
|
|
- # 底部信息栏
|
|
|
- footer_parts = []
|
|
|
- if elapsed > 0:
|
|
|
- footer_parts.append(f"⏱ {elapsed:.1f}s")
|
|
|
- if steps > 0:
|
|
|
- footer_parts.append(f"📊 {steps} steps")
|
|
|
- if tokens > 0:
|
|
|
- footer_parts.append(f"🔤 {tokens} tokens")
|
|
|
-
|
|
|
- if footer_parts:
|
|
|
- elements.append({"tag": "hr"})
|
|
|
- elements.append({
|
|
|
- "tag": "note",
|
|
|
- "elements": [
|
|
|
- {"tag": "plain_text", "content": " | ".join(footer_parts)},
|
|
|
- ],
|
|
|
- })
|
|
|
-
|
|
|
- return {
|
|
|
- "header": {
|
|
|
- "title": {"tag": "plain_text", "content": "🐂 lambda"},
|
|
|
- "template": "green",
|
|
|
- },
|
|
|
- "elements": elements,
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-def build_error_card(error: str) -> dict:
|
|
|
- """构建错误卡片。"""
|
|
|
- return {
|
|
|
- "header": {
|
|
|
- "title": {"tag": "plain_text", "content": "🐂 lambda"},
|
|
|
- "template": "red",
|
|
|
- },
|
|
|
- "elements": [
|
|
|
- {"tag": "div", "text": {"tag": "lark_md", "content": f"❌ {error[:500]}"}},
|
|
|
- ],
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-def _clean_for_feishu(text: str) -> str:
|
|
|
- """清理 Markdown 使其兼容飞书卡片。"""
|
|
|
- # 飞书不支持 ``` 代码块语法高亮标记
|
|
|
- text = re.sub(r'```(\w+)\n', '```\n', text)
|
|
|
- # 截断过长内容
|
|
|
- if len(text) > 3000:
|
|
|
- text = text[:3000] + "\n\n... [内容过长,已截断]"
|
|
|
- return text
|
|
|
-
|
|
|
-
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-# 事件处理 (参考 reply-dispatcher.js)
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-
|
|
|
-_processed: set = set()
|
|
|
-_MAX_EVENTS = 2000
|
|
|
-
|
|
|
-
|
|
|
-def handle_event(event_data: dict, agent_runner: Callable = None) -> dict:
|
|
|
- """处理飞书事件回调。"""
|
|
|
- # URL 验证
|
|
|
- if "challenge" in event_data:
|
|
|
- return {"challenge": event_data["challenge"]}
|
|
|
-
|
|
|
- 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:
|
|
|
- return {"code": 0, "msg": "duplicate"}
|
|
|
- _processed.add(event_id)
|
|
|
- if len(_processed) > _MAX_EVENTS:
|
|
|
- _processed.clear()
|
|
|
-
|
|
|
- if event_type == "im.message.receive_v1":
|
|
|
- return _handle_message(event, agent_runner)
|
|
|
-
|
|
|
- return {"code": 0, "msg": f"unhandled: {event_type}"}
|
|
|
-
|
|
|
-
|
|
|
-def _handle_message(event: dict, agent_runner: Callable) -> dict:
|
|
|
- """处理消息事件。"""
|
|
|
- message = event.get("message", {})
|
|
|
- sender = event.get("sender", {}).get("sender_id", {})
|
|
|
- msg_type = message.get("message_type", "")
|
|
|
- message_id = message.get("message_id", "")
|
|
|
- chat_id = message.get("chat_id", "")
|
|
|
- chat_type = message.get("chat_type", "") # p2p / group
|
|
|
-
|
|
|
- # 群聊 @机器人 检测
|
|
|
- if chat_type == "group" and REQUIRE_MENTION:
|
|
|
- mentions = message.get("mentions", [])
|
|
|
- bot_mentioned = any(m.get("id", {}).get("open_id", "") == _get_bot_id()
|
|
|
- for m in mentions)
|
|
|
- if not bot_mentioned:
|
|
|
- # 没有 @机器人,忽略
|
|
|
- return {"code": 0, "msg": "not mentioned"}
|
|
|
-
|
|
|
- # 只处理文本
|
|
|
- if msg_type != "text":
|
|
|
- reply_text(message_id, "🐂 目前只支持文本消息")
|
|
|
- return {"code": 0}
|
|
|
-
|
|
|
- # 提取文本
|
|
|
- try:
|
|
|
- content = json.loads(message.get("content", "{}"))
|
|
|
- text = content.get("text", "").strip()
|
|
|
- except Exception:
|
|
|
- text = ""
|
|
|
-
|
|
|
- # 去掉 @bot 标记
|
|
|
- text = re.sub(r'@_user_\d+\s*', '', text).strip()
|
|
|
- if not text:
|
|
|
- return {"code": 0}
|
|
|
-
|
|
|
- # 执行 Agent
|
|
|
- if not agent_runner:
|
|
|
- reply_text(message_id, "🐂 Agent 未绑定")
|
|
|
- return {"code": 0}
|
|
|
-
|
|
|
- import threading
|
|
|
- threading.Thread(
|
|
|
- target=_process_and_reply,
|
|
|
- args=(message_id, chat_id, text, agent_runner),
|
|
|
- daemon=True,
|
|
|
- ).start()
|
|
|
-
|
|
|
- return {"code": 0}
|
|
|
-
|
|
|
-
|
|
|
-def _process_and_reply(message_id: str, chat_id: str, text: str,
|
|
|
- agent_runner: Callable):
|
|
|
- """在后台线程中处理消息并回复(不阻塞 webhook 响应)。"""
|
|
|
- # 1. 先发"思考中"卡片
|
|
|
- thinking_resp = reply_card(message_id, build_thinking_card())
|
|
|
- thinking_msg_id = ""
|
|
|
- try:
|
|
|
- thinking_msg_id = thinking_resp.get("data", {}).get("message_id", "")
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
-
|
|
|
- # 2. 调用 Agent
|
|
|
- t0 = time.time()
|
|
|
- try:
|
|
|
- result = agent_runner(text)
|
|
|
- elapsed = time.time() - t0
|
|
|
-
|
|
|
- # 3. 用结果卡片替换"思考中"
|
|
|
- result_card = build_result_card(str(result), elapsed=elapsed)
|
|
|
-
|
|
|
- if thinking_msg_id:
|
|
|
- update_card(thinking_msg_id, result_card)
|
|
|
- else:
|
|
|
- send_card(chat_id, result_card)
|
|
|
-
|
|
|
- except Exception as e:
|
|
|
- error_card = build_error_card(str(e))
|
|
|
- if thinking_msg_id:
|
|
|
- update_card(thinking_msg_id, error_card)
|
|
|
- else:
|
|
|
- send_card(chat_id, error_card)
|
|
|
-
|
|
|
-
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-# Bot 身份
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-
|
|
|
-_bot_info: dict = {}
|
|
|
-
|
|
|
-
|
|
|
-def _get_bot_id() -> str:
|
|
|
- global _bot_info
|
|
|
- if not _bot_info:
|
|
|
- resp = _feishu_get("/bot/v3/info")
|
|
|
- _bot_info = resp.get("bot", {})
|
|
|
- return _bot_info.get("open_id", "")
|
|
|
-
|
|
|
-
|
|
|
-def get_bot_info() -> dict:
|
|
|
- _get_bot_id()
|
|
|
- return _bot_info
|
|
|
-
|
|
|
-
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-# Agent Runner (PaaS API)
|
|
|
-# ════════════════════════════════════════════════════════════
|
|
|
-
|
|
|
-def make_paas_runner(agent_id: str, api_key: str = "",
|
|
|
- server: str = "http://localhost:8000") -> Callable:
|
|
|
- """创建 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
|