From 577dabde2ad9678727d5905f3bf564f5dcf41ad1 Mon Sep 17 00:00:00 2001 From: zlei9 Date: Sun, 29 Mar 2026 08:20:46 +0800 Subject: [PATCH] Initial commit with translated description --- README.md | 172 ++++++++++++++++++++++++++++ SKILL.md | 78 +++++++++++++ _meta.json | 6 + bridge.mjs | 277 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 18 +++ setup-service.mjs | 82 ++++++++++++++ 6 files changed, 633 insertions(+) create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 _meta.json create mode 100644 bridge.mjs create mode 100644 package.json create mode 100644 setup-service.mjs diff --git a/README.md b/README.md new file mode 100644 index 0000000..11f3cf8 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# 飞书 × Clawdbot 桥接器 + +> 让你的 Clawdbot 智能体直接在飞书里对话——无需公网服务器、无需域名、无需备案。 + +--- + +## 它是怎么工作的? + +想象三个角色: + +``` +飞书用户 ←→ 飞书云端 ←→ 桥接脚本(你的电脑上) ←→ Clawdbot 智能体 +``` + +### 通俗解释 + +1. **飞书那边**:你在飞书开发者后台创建一个"自建应用"(机器人),飞书会给你一个 App ID 和 App Secret——这就像是机器人的"身份证"。 + +2. **桥接脚本**:一个运行在你电脑上的小程序。它用飞书提供的 **WebSocket 长连接**(而不是传统的 Webhook)来接收消息——这意味着: + - ✅ 不需要公网 IP / 域名 + - ✅ 不需要 ngrok / frp 等内网穿透 + - ✅ 不需要 HTTPS 证书 + - 就像微信一样,你的客户端主动连上去,消息就推过来了 + +3. **Clawdbot**:桥接脚本收到飞书消息后,通过本地 WebSocket 转发给 Clawdbot Gateway。Clawdbot 调用 AI 模型生成回复,桥接脚本再把回复发回飞书。 + +### 保活机制 + +脚本通过 macOS 的 **launchd**(系统服务管理器)运行: +- 开机自动启动 +- 崩溃自动重启 +- 日志自动写入文件 + +就像把一个程序设成了"开机启动项",但更可靠。 + +--- + +## 5 分钟上手 + +### 前提 + +- macOS(已安装 Clawdbot 并正常运行) +- Node.js ≥ 18 +- Clawdbot Gateway 已启动(`clawdbot gateway status` 检查) + +### 第一步:创建飞书机器人 + +1. 打开 [飞书开放平台](https://open.feishu.cn/app),登录 +2. 点击 **创建自建应用** +3. 填写应用名称(随意,比如 "My AI Assistant") +4. 进入应用 → **添加应用能力** → 选择 **机器人** +5. 进入 **权限管理**,开通以下权限: + - `im:message` — 获取与发送单聊、群聊消息 + - `im:message.group_at_msg` — 接收群聊中 @ 机器人的消息 + - `im:message.p2p_msg` — 接收机器人单聊消息 +6. 进入 **事件与回调** → **事件配置**: + - 添加事件:`接收消息 im.message.receive_v1` + - 请求方式选择:**使用长连接接收事件**(这是关键!) +7. 发布应用(创建版本 → 申请上线) +8. 记下 **App ID** 和 **App Secret**(在"凭证与基础信息"页面) + +### 第二步:安装依赖 + +```bash +cd feishu-bridge +npm install +``` + +### 第三步:配置凭证 + +把你的飞书 App Secret 保存到安全位置: + +```bash +# 创建 secrets 目录 +mkdir -p ~/.clawdbot/secrets + +# 写入 secret(替换成你自己的) +echo "你的AppSecret" > ~/.clawdbot/secrets/feishu_app_secret + +# 设置权限,只有自己能读 +chmod 600 ~/.clawdbot/secrets/feishu_app_secret +``` + +### 第四步:测试运行 + +```bash +# 替换成你的 App ID +FEISHU_APP_ID=cli_xxxxxxxxx node bridge.mjs +``` + +在飞书里给机器人发一条消息,看到回复就说明成功了 🎉 + +### 第五步:设置开机自启(可选但推荐) + +```bash +# 生成 launchd 服务配置(自动检测路径) +node setup-service.mjs + +# 加载服务 +launchctl load ~/Library/LaunchAgents/com.clawdbot.feishu-bridge.plist + +# 查看状态 +launchctl list | grep feishu +``` + +之后电脑重启也会自动连上。 + +--- + +## 文件说明 + +``` +feishu-bridge/ +├── bridge.mjs # 核心桥接脚本(~200行) +├── setup-service.mjs # 自动生成 launchd 保活配置 +├── package.json # 依赖声明 +├── .env.example # 环境变量示例 +└── README.md # 你正在读的这个 +``` + +--- + +## 进阶 + +### 群聊行为 + +在群聊中,桥接器默认"低打扰"模式——只在以下情况回复: +- 被 @ 了 +- 消息看起来是提问(以 `?` / `?` 结尾) +- 消息包含请求类动词(帮、请、分析、总结、写…) +- 用名字呼唤(bot、助手…,可在代码中自定义) + +其他闲聊不会回复,避免刷屏。 + +### "正在思考…" 提示 + +如果 AI 回复超过 2.5 秒,会先发一条"正在思考…",等回复生成后自动替换成完整内容。 + +### 日志位置 + +``` +~/.clawdbot/logs/feishu-bridge.out.log # 正常输出 +~/.clawdbot/logs/feishu-bridge.err.log # 错误日志 +``` + +### 停止服务 + +```bash +launchctl unload ~/Library/LaunchAgents/com.clawdbot.feishu-bridge.plist +``` + +--- + +## 常见问题 + +**Q: 需要服务器吗?** +不需要。飞书的 WebSocket 长连接模式让你的电脑直接连到飞书云端,不需要公网暴露。 + +**Q: 电脑关机了怎么办?** +机器人会离线。重新开机后 launchd 会自动重启桥接服务。如需 24/7 在线,可以部署到一台常开的机器(比如 NAS、云服务器、甚至树莓派)。 + +**Q: 飞书免费版能用吗?** +可以。自建应用和机器人能力对所有飞书版本开放。 + +**Q: 能同时接 Telegram / 微信吗?** +可以。Clawdbot 原生支持 Telegram 等渠道,飞书桥接只是多加一个入口,互不影响。 + +--- + +## License + +MIT diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..84900d0 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,78 @@ +--- +name: feishu-bridge +description: "通过WebSocket长连接将飞书(Lark)机器人连接到Clawdbot。无需公共服务器、域名或ngrok。在将飞书/Lark设置为消息通道、排查飞书桥接问题或管理桥接服务(启动/停止/日志)时使用。涵盖飞书开放平台上的机器人创建、凭证设置、桥接启动、macOS launchd自动重启和群组聊天行为调优。" +--- + +# Feishu Bridge + +Bridge Feishu bot messages to Clawdbot Gateway over local WebSocket. + +## Architecture + +``` +Feishu user → Feishu cloud ←WS→ bridge.mjs (local) ←WS→ Clawdbot Gateway → AI agent +``` + +- Feishu SDK connects outbound (no inbound port / public IP needed) +- Bridge authenticates to Gateway using the existing gateway token +- Each Feishu chat maps to a Clawdbot session (`feishu:`) + +## Setup + +### 1. Create Feishu bot + +1. Go to [open.feishu.cn/app](https://open.feishu.cn/app) → Create self-built app → Add **Bot** capability +2. Enable permissions: `im:message`, `im:message.group_at_msg`, `im:message.p2p_msg` +3. Events: add `im.message.receive_v1`, set delivery to **WebSocket long-connection** +4. Publish the app (create version → request approval) +5. Note the **App ID** and **App Secret** + +### 2. Store secret + +```bash +mkdir -p ~/.clawdbot/secrets +echo "YOUR_APP_SECRET" > ~/.clawdbot/secrets/feishu_app_secret +chmod 600 ~/.clawdbot/secrets/feishu_app_secret +``` + +### 3. Install & run + +```bash +cd /feishu-bridge +npm install +FEISHU_APP_ID=cli_xxx node bridge.mjs +``` + +### 4. Auto-start (macOS) + +```bash +FEISHU_APP_ID=cli_xxx node setup-service.mjs +launchctl load ~/Library/LaunchAgents/com.clawdbot.feishu-bridge.plist +``` + +## Diagnostics + +```bash +# Check service +launchctl list | grep feishu + +# Logs +tail -f ~/.clawdbot/logs/feishu-bridge.err.log + +# Stop +launchctl unload ~/Library/LaunchAgents/com.clawdbot.feishu-bridge.plist +``` + +## Group chat behavior + +Bridge replies only when: user @-mentions the bot, message ends with `?`/`?`, contains request verbs (帮/请/分析/总结…), or calls the bot by name. Customize the name list in `bridge.mjs` → `shouldRespondInGroup()`. + +## Environment variables + +| Variable | Required | Default | +|---|---|---| +| `FEISHU_APP_ID` | ✅ | — | +| `FEISHU_APP_SECRET_PATH` | — | `~/.clawdbot/secrets/feishu_app_secret` | +| `CLAWDBOT_CONFIG_PATH` | — | `~/.clawdbot/clawdbot.json` | +| `CLAWDBOT_AGENT_ID` | — | `main` | +| `FEISHU_THINKING_THRESHOLD_MS` | — | `2500` | diff --git a/_meta.json b/_meta.json new file mode 100644 index 0000000..00ddfb8 --- /dev/null +++ b/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn7fbmwh43pckdfqgpphb7x40x8019n8", + "slug": "feishu-bridge", + "version": "1.0.0", + "publishedAt": 1769499526377 +} \ No newline at end of file diff --git a/bridge.mjs b/bridge.mjs new file mode 100644 index 0000000..00b540b --- /dev/null +++ b/bridge.mjs @@ -0,0 +1,277 @@ +/** + * Feishu ↔ Clawdbot Bridge + * + * Receives messages from Feishu via WebSocket (long connection), + * forwards them to Clawdbot Gateway, and sends the AI reply back. + * + * No public server / domain / HTTPS required. + */ + +import * as Lark from '@larksuiteoapi/node-sdk'; +import fs from 'node:fs'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import WebSocket from 'ws'; + +// ─── Config ────────────────────────────────────────────────────── + +const APP_ID = process.env.FEISHU_APP_ID; +const APP_SECRET_PATH = resolve(process.env.FEISHU_APP_SECRET_PATH || '~/.clawdbot/secrets/feishu_app_secret'); +const CLAWDBOT_CONFIG_PATH = resolve(process.env.CLAWDBOT_CONFIG_PATH || '~/.clawdbot/clawdbot.json'); +const CLAWDBOT_AGENT_ID = process.env.CLAWDBOT_AGENT_ID || 'main'; +const THINKING_THRESHOLD_MS = Number(process.env.FEISHU_THINKING_THRESHOLD_MS ?? 2500); + +// ─── Helpers ───────────────────────────────────────────────────── + +function resolve(p) { + return p.replace(/^~/, os.homedir()); +} + +function mustRead(filePath, label) { + const resolved = resolve(filePath); + if (!fs.existsSync(resolved)) { + console.error(`[FATAL] ${label} not found: ${resolved}`); + process.exit(1); + } + const val = fs.readFileSync(resolved, 'utf8').trim(); + if (!val) { + console.error(`[FATAL] ${label} is empty: ${resolved}`); + process.exit(1); + } + return val; +} + +const uuid = () => crypto.randomUUID(); + +// ─── Load secrets & config ─────────────────────────────────────── + +if (!APP_ID) { + console.error('[FATAL] FEISHU_APP_ID environment variable is required'); + process.exit(1); +} + +const APP_SECRET = mustRead(APP_SECRET_PATH, 'Feishu App Secret'); +const clawdConfig = JSON.parse(mustRead(CLAWDBOT_CONFIG_PATH, 'Clawdbot config')); + +const GATEWAY_PORT = clawdConfig?.gateway?.port || 18789; +const GATEWAY_TOKEN = clawdConfig?.gateway?.auth?.token; + +if (!GATEWAY_TOKEN) { + console.error('[FATAL] gateway.auth.token missing in Clawdbot config'); + process.exit(1); +} + +// ─── Feishu SDK setup ──────────────────────────────────────────── + +const sdkConfig = { + appId: APP_ID, + appSecret: APP_SECRET, + domain: Lark.Domain.Feishu, + appType: Lark.AppType.SelfBuild, +}; + +const client = new Lark.Client(sdkConfig); +const wsClient = new Lark.WSClient({ ...sdkConfig, loggerLevel: Lark.LoggerLevel.info }); + +// ─── Dedup (Feishu may deliver the same event more than once) ──── + +const seen = new Map(); +const SEEN_TTL_MS = 10 * 60 * 1000; + +function isDuplicate(messageId) { + const now = Date.now(); + // Garbage-collect old entries + for (const [k, ts] of seen) { + if (now - ts > SEEN_TTL_MS) seen.delete(k); + } + if (!messageId) return false; + if (seen.has(messageId)) return true; + seen.set(messageId, now); + return false; +} + +// ─── Talk to Clawdbot Gateway ──────────────────────────────────── + +async function askClawdbot({ text, sessionKey }) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${GATEWAY_PORT}`); + let runId = null; + let buf = ''; + const close = () => { try { ws.close(); } catch {} }; + + ws.on('error', (e) => { close(); reject(e); }); + + ws.on('message', (raw) => { + let msg; + try { msg = JSON.parse(raw.toString()); } catch { return; } + + // Step 1: Gateway sends connect challenge → we authenticate + if (msg.type === 'event' && msg.event === 'connect.challenge') { + ws.send(JSON.stringify({ + type: 'req', + id: 'connect', + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 3, + client: { id: 'gateway-client', version: '0.2.0', platform: 'macos', mode: 'backend' }, + role: 'operator', + scopes: ['operator.read', 'operator.write'], + auth: { token: GATEWAY_TOKEN }, + locale: 'zh-CN', + userAgent: 'feishu-clawdbot-bridge', + }, + })); + return; + } + + // Step 2: Connect response → send the user message + if (msg.type === 'res' && msg.id === 'connect') { + if (!msg.ok) { close(); reject(new Error(msg.error?.message || 'connect failed')); return; } + ws.send(JSON.stringify({ + type: 'req', + id: 'agent', + method: 'agent', + params: { + message: text, + agentId: CLAWDBOT_AGENT_ID, + sessionKey, + deliver: false, + idempotencyKey: uuid(), + }, + })); + return; + } + + // Step 3: Agent run accepted + if (msg.type === 'res' && msg.id === 'agent') { + if (!msg.ok) { close(); reject(new Error(msg.error?.message || 'agent error')); return; } + if (msg.payload?.runId) runId = msg.payload.runId; + return; + } + + // Step 4: Stream the response + if (msg.type === 'event' && msg.event === 'agent') { + const p = msg.payload; + if (!p || (runId && p.runId !== runId)) return; + + if (p.stream === 'assistant') { + const d = p.data || {}; + if (typeof d.text === 'string') buf = d.text; + else if (typeof d.delta === 'string') buf += d.delta; + return; + } + + if (p.stream === 'lifecycle') { + if (p.data?.phase === 'end') { close(); resolve(buf.trim()); } + if (p.data?.phase === 'error') { close(); reject(new Error(p.data?.message || 'agent error')); } + } + } + }); + }); +} + +// ─── Group chat intelligence ───────────────────────────────────── +// +// In group chats, only respond when the message looks like a real +// question, request, or direct address — avoids spamming. + +function shouldRespondInGroup(text, mentions) { + if (mentions.length > 0) return true; + const t = text.toLowerCase(); + if (/[??]$/.test(text)) return true; + if (/\b(why|how|what|when|where|who|help)\b/.test(t)) return true; + const verbs = ['帮', '麻烦', '请', '能否', '可以', '解释', '看看', '排查', '分析', '总结', '写', '改', '修', '查', '对比', '翻译']; + if (verbs.some(k => text.includes(k))) return true; + // Customize this list with your bot's name + if (/^(clawdbot|bot|助手|智能体)[\s,:,:]/i.test(text)) return true; + return false; +} + +// ─── Message handler ───────────────────────────────────────────── + +const dispatcher = new Lark.EventDispatcher({}).register({ + 'im.message.receive_v1': async (data) => { + try { + const { message } = data; + const chatId = message?.chat_id; + if (!chatId) return; + + // Dedup + if (isDuplicate(message?.message_id)) return; + + // Only handle text messages + if (message?.message_type !== 'text' || !message?.content) return; + + let text = (JSON.parse(message.content)?.text || '').trim(); + if (!text) return; + + // Group chat: check if we should respond + if (message?.chat_type === 'group') { + const mentions = Array.isArray(message?.mentions) ? message.mentions : []; + text = text.replace(/@_user_\d+\s*/g, '').trim(); + if (!text || !shouldRespondInGroup(text, mentions)) return; + } + + const sessionKey = `feishu:${chatId}`; + + // Process asynchronously + setImmediate(async () => { + let placeholderId = ''; + let done = false; + + // Show "thinking…" if reply takes too long + const timer = THINKING_THRESHOLD_MS > 0 + ? setTimeout(async () => { + if (done) return; + try { + const res = await client.im.v1.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text: '正在思考…' }) }, + }); + placeholderId = res?.data?.message_id || ''; + } catch {} + }, THINKING_THRESHOLD_MS) + : null; + + let reply = ''; + try { + reply = await askClawdbot({ text, sessionKey }); + } catch (e) { + reply = `(系统出错)${e?.message || String(e)}`; + } finally { + done = true; + if (timer) clearTimeout(timer); + } + + // Skip empty or NO_REPLY + if (!reply || reply === 'NO_REPLY') return; + + // If we sent "thinking…", update it; otherwise send new message + if (placeholderId) { + try { + await client.im.v1.message.update({ + path: { message_id: placeholderId }, + data: { msg_type: 'text', content: JSON.stringify({ text: reply }) }, + }); + return; + } catch { + // Fall through to send new + } + } + + await client.im.v1.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text: reply }) }, + }); + }); + } catch (e) { + console.error('[ERROR] message handler:', e); + } + }, +}); + +// ─── Start ─────────────────────────────────────────────────────── + +wsClient.start({ eventDispatcher: dispatcher }); +console.log(`[OK] Feishu bridge started (appId=${APP_ID})`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..32af587 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "feishu-clawdbot-bridge", + "version": "1.0.0", + "description": "Connect Feishu/Lark bot to Clawdbot agent via WebSocket — no public server needed", + "type": "module", + "scripts": { + "start": "node bridge.mjs", + "setup": "node setup-service.mjs" + }, + "dependencies": { + "@larksuiteoapi/node-sdk": "^1.56.1", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/setup-service.mjs b/setup-service.mjs new file mode 100644 index 0000000..41d6dfc --- /dev/null +++ b/setup-service.mjs @@ -0,0 +1,82 @@ +/** + * Generate a macOS launchd plist to keep the Feishu bridge running. + * + * Usage: + * FEISHU_APP_ID=cli_xxx node setup-service.mjs + * + * Then: + * launchctl load ~/Library/LaunchAgents/com.clawdbot.feishu-bridge.plist + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const APP_ID = process.env.FEISHU_APP_ID; +if (!APP_ID) { + console.error('Please set FEISHU_APP_ID environment variable'); + process.exit(1); +} + +const HOME = os.homedir(); +const NODE_PATH = process.execPath; // e.g. /opt/homebrew/bin/node +const BRIDGE_PATH = path.resolve(import.meta.dirname, 'bridge.mjs'); +const WORK_DIR = path.resolve(import.meta.dirname); +const LABEL = 'com.clawdbot.feishu-bridge'; +const SECRET_PATH = process.env.FEISHU_APP_SECRET_PATH || `${HOME}/.clawdbot/secrets/feishu_app_secret`; + +const plist = ` + + + + Label + ${LABEL} + + ProgramArguments + + ${NODE_PATH} + ${BRIDGE_PATH} + + + WorkingDirectory + ${WORK_DIR} + + RunAtLoad + + + KeepAlive + + + EnvironmentVariables + + HOME + ${HOME} + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + FEISHU_APP_ID + ${APP_ID} + FEISHU_APP_SECRET_PATH + ${SECRET_PATH} + + + StandardOutPath + ${HOME}/.clawdbot/logs/feishu-bridge.out.log + StandardErrorPath + ${HOME}/.clawdbot/logs/feishu-bridge.err.log + + +`; + +// Ensure logs dir +fs.mkdirSync(`${HOME}/.clawdbot/logs`, { recursive: true }); + +const outPath = path.join(HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`); +fs.mkdirSync(path.dirname(outPath), { recursive: true }); +fs.writeFileSync(outPath, plist); +console.log(`✅ Wrote: ${outPath}`); +console.log(); +console.log('To start the service:'); +console.log(` launchctl load ${outPath}`); +console.log(); +console.log('To stop:'); +console.log(` launchctl unload ${outPath}`);