278 lines
9.7 KiB
JavaScript
278 lines
9.7 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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})`);
|