Files
alexanys_feishu-bridge/bridge.mjs

278 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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})`);