Initial commit with translated description
This commit is contained in:
172
README.md
Normal file
172
README.md
Normal file
@@ -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
|
||||
78
SKILL.md
Normal file
78
SKILL.md
Normal file
@@ -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:<chatId>`)
|
||||
|
||||
## 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 <skill-dir>/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` |
|
||||
6
_meta.json
Normal file
6
_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7fbmwh43pckdfqgpphb7x40x8019n8",
|
||||
"slug": "feishu-bridge",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1769499526377
|
||||
}
|
||||
277
bridge.mjs
Normal file
277
bridge.mjs
Normal file
@@ -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})`);
|
||||
18
package.json
Normal file
18
package.json
Normal file
@@ -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"
|
||||
}
|
||||
82
setup-service.mjs
Normal file
82
setup-service.mjs
Normal file
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${LABEL}</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${NODE_PATH}</string>
|
||||
<string>${BRIDGE_PATH}</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${WORK_DIR}</string>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HOME</key>
|
||||
<string>${HOME}</string>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
<key>FEISHU_APP_ID</key>
|
||||
<string>${APP_ID}</string>
|
||||
<key>FEISHU_APP_SECRET_PATH</key>
|
||||
<string>${SECRET_PATH}</string>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>${HOME}/.clawdbot/logs/feishu-bridge.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${HOME}/.clawdbot/logs/feishu-bridge.err.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
|
||||
// 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}`);
|
||||
Reference in New Issue
Block a user