codev / src /services /feishu /FeishuService.ts
chenbhao's picture
chore: rename VersperClaw to Codev, update org to chenbhao
96f34e3
Raw
History Blame Contribute Delete
22.5 kB
/**
* FeishuService — in-process Feishu bot using @larksuite/channel
*/
import * as path from 'node:path'
import * as os from 'node:os'
import {
createLarkChannel,
type LarkChannel,
type NormalizedMessage,
registerApp,
} from '@larksuite/channel'
import {
getFeishuConfig,
saveFeishuConfig,
type FeishuRuntimeConfig,
} from './feishuConfig.js'
// Vendor modules (self-contained implementations)
import { log } from './vendor/core/logger.js'
import { PendingQueue } from './vendor/bot/pending-queue.js'
import { startKeepalive } from './vendor/bot/keepalive.js'
import { ChatModeCache } from './vendor/bot/chat-mode-cache.js'
import { createOwnerRefreshController } from './vendor/policy/owner.js'
import { fetchQuotedContext, renderQuotedBlock } from './vendor/bot/quote.js'
// ──────────────────────────────────────────────
// Minimal RuntimeControls interface (matches vendor/policy/access.ts)
// ──────────────────────────────────────────────
interface RuntimeControls {
botOwnerId?: string
ownerRefreshState: 'ok' | 'failed' | 'unknown'
ownerRefreshedAt?: number
ownerRefreshError?: string
}
// ──────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────
type FeishuServiceState = {
status: 'stopped' | 'starting' | 'running' | 'error'
appId?: string
botName?: string
ownerId?: string
lastError?: string
}
type Listener = () => void
/** Inbound event for bridge hook consumption */
type FeishuInboundEvent = {
chatId: string
text: string
senderId: string
}
type InboundListener = (event: FeishuInboundEvent) => void
type AccessDecision = {
ok: boolean
reason: string
}
// Minimal RuntimeControls implementation for owner refresh
class FeishuRuntimeControls implements RuntimeControls {
botOwnerId?: string
ownerRefreshState: 'ok' | 'failed' | 'unknown' = 'unknown'
ownerRefreshedAt?: number
ownerRefreshError?: string
}
// ──────────────────────────────────────────────
// Access control helpers
// ──────────────────────────────────────────────
function isOwner(controls: FeishuRuntimeControls, senderId: string): boolean {
if (controls.ownerRefreshState === 'unknown') return false
return Boolean(controls.botOwnerId) && controls.botOwnerId === senderId
}
function canUseDm(
config: FeishuRuntimeConfig,
controls: FeishuRuntimeControls,
senderId: string,
): AccessDecision {
if (isOwner(controls, senderId)) return { ok: true, reason: 'owner' }
if (config.admins?.includes(senderId)) return { ok: true, reason: 'admin' }
if (!config.allowedUsers || config.allowedUsers.length === 0) {
return { ok: true, reason: 'open-dm' }
}
if (config.allowedUsers.includes(senderId)) return { ok: true, reason: 'allowed-user' }
return { ok: false, reason: 'denied-user' }
}
function canUseGroup(
config: FeishuRuntimeConfig,
controls: FeishuRuntimeControls,
chatId: string,
senderId: string,
): AccessDecision {
if (isOwner(controls, senderId)) return { ok: true, reason: 'owner' }
if (config.admins?.includes(senderId)) return { ok: true, reason: 'admin' }
if (config.allowedChats?.includes(chatId)) return { ok: true, reason: 'allowed-chat' }
return { ok: false, reason: 'denied-chat' }
}
function canRunAdminCommand(
config: FeishuRuntimeConfig,
controls: FeishuRuntimeControls,
senderId: string,
): AccessDecision {
if (isOwner(controls, senderId)) return { ok: true, reason: 'owner' }
if (config.admins?.includes(senderId)) return { ok: true, reason: 'admin' }
return { ok: false, reason: 'denied-admin' }
}
// ──────────────────────────────────────────────
// Slash command handler type
// ──────────────────────────────────────────────
type CommandHandler = (
args: string,
chatId: string,
senderId: string,
channel: LarkChannel,
) => Promise<boolean>
// ──────────────────────────────────────────────
// Main service
// ──────────────────────────────────────────────
class FeishuService {
private listeners = new Set<Listener>()
private inboundListeners = new Set<InboundListener>()
private state: FeishuServiceState = { status: 'stopped' }
private config?: FeishuRuntimeConfig
private channel?: LarkChannel
private pending?: PendingQueue
private keepalive?: { stop(): void }
private ownerRefresh?: { start(): Promise<void>; stop(): void }
private chatModeCache?: ChatModeCache
private controls = new FeishuRuntimeControls()
private runId = 0
private commandHandlers = new Map<string, CommandHandler>()
constructor() {
this.registerDefaultCommands()
}
// ── Default slash commands ──────────────────
private registerDefaultCommands(): void {
this.commandHandlers.set('/stop', async (_args, chatId, _senderId, channel) => {
await channel.send(chatId, { text: '⏹ 停止命令已收到。' })
return true
})
this.commandHandlers.set('/reset', async (_args, chatId, _senderId, channel) => {
await channel.send(chatId, { text: '🔄 已重置会话。' })
return true
})
this.commandHandlers.set(
'/status',
async (_args, chatId, _senderId, channel) => {
const cfg = this.config
const lines = [
`状态: ${this.state.status}`,
`App ID: ${cfg?.appId ?? '未配置'}`,
`Bot: ${this.state.botName ?? '未知'}`,
`Owner: ${this.controls.botOwnerId ?? '未知'}`,
`DM 策略: ${
!cfg?.allowedUsers || cfg.allowedUsers.length === 0
? '所有人'
: '白名单'
}`,
`群聊策略: ${
cfg?.requireMentionInGroup !== false ? '需 @bot' : '所有消息'
}`,
`配置群白名单: ${cfg?.allowedChats?.length ?? 0} 个`,
]
await channel.send(chatId, { text: lines.join('\n') })
return true
},
)
this.commandHandlers.set('/help', async (_args, chatId, _senderId, channel) => {
const help = [
'飞书 Bot 命令:',
'/stop — 中断当前任务',
'/reset — 重置会话',
'/status — 查看状态',
'/help — 显示此帮助',
]
await channel.send(chatId, { text: help.join('\n') })
return true
})
}
// ── React sync external store interface ─────
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
subscribeToInbound = (listener: InboundListener): (() => void) => {
this.inboundListeners.add(listener)
return () => this.inboundListeners.delete(listener)
}
getStateSnapshot = (): FeishuServiceState => this.state
// ── Lifecycle ───────────────────────────────
async start(config: FeishuRuntimeConfig): Promise<void> {
if (
this.state.status === 'running' &&
this.config?.appId === config.appId
) {
return
}
await this.stop()
const runId = ++this.runId
this.config = config
this.setState({
status: 'starting',
appId: config.appId,
lastError: undefined,
})
try {
const domain =
config.tenant === 'lark'
? 'https://open.larksuite.com'
: 'https://open.feishu.cn'
const channel = createLarkChannel({
appId: config.appId!,
appSecret: config.appSecret!,
domain,
source: 'codev',
// Use default logger level (info)
respectProxyEnv: true,
wsConfig: { pingTimeout: 3 },
handshakeTimeoutMs: 8000,
httpTimeoutMs: 30000,
policy: {
dmMode: 'open',
requireMention: false,
respondToMentionAll: false,
},
safety: { chatQueue: { enabled: false } },
includeRawEvent: true,
})
const chatModeCache = new ChatModeCache()
const cfg = config // local ref for closure
// Pending queue with block/unblock support
const pending = new PendingQueue(600, async (scope, batch) => {
const first = batch[0]
if (!first) return
const chatId = first.chatId
const senderId = first.senderId
const text = batch.map(m => m.content).join('\n---\n')
log.info('flush', 'start', { scope, batchSize: batch.length })
try {
const mode = await chatModeCache.resolve(channel, chatId)
const isGroup = mode !== 'p2p'
// Access check
if (isGroup) {
const access = canUseGroup(cfg, this.controls, chatId, senderId)
if (!access.ok) {
if (first.mentionedBot) {
await channel.send(chatId, {
text: '当前群未加入响应列表。请联系 Bot owner 或管理员使用 /invite group 加入白名单。',
})
}
log.info('intake', 'denied-group', {
chatId,
senderId,
reason: access.reason,
})
return
}
// @mention policy — default requires mention in groups
if (cfg.requireMentionInGroup !== false && !first.mentionedBot) {
log.info('intake', 'skip-no-mention', { chatId })
return
}
} else {
const access = canUseDm(cfg, this.controls, senderId)
if (!access.ok) {
log.info('intake', 'denied-dm', {
chatId,
senderId,
reason: access.reason,
})
return
}
}
// Slash command routing
if (text.startsWith('/')) {
const parts = text.split(/\s+/)
const cmdName = parts[0] ?? ''
const args = parts.slice(1).join(' ')
const handler = this.commandHandlers.get(cmdName)
if (handler) {
await handler(args, chatId, senderId, channel)
return // handled locally
}
// Unknown command — let it pass through to the agent
}
// Build prompt with optional quoted context
let fullText = text
const replyToId = first.replyToMessageId
if (replyToId) {
const quote = await fetchQuotedContext(channel, replyToId)
if (quote) {
const quotedBlock = renderQuotedBlock([quote])
if (quotedBlock) {
fullText = `${quotedBlock}\n\n${text}`
}
}
}
// Notify inbound listeners (bridge hook uses this for chatId tracking)
for (const listener of this.inboundListeners) {
listener({ chatId, text: fullText, senderId })
}
// Enqueue to REPL
const { enqueue } = await import(
'../../utils/messageQueueManager.js'
)
enqueue({
value: fullText,
mode: 'prompt',
skipSlashCommands: true,
bridgeOrigin: true,
origin: { kind: 'channel', server: 'feishu' },
})
} finally {
log.info('flush', 'end')
}
})
channel.on({
message: async (msg: NormalizedMessage) => {
if (runId !== this.runId) return
try {
pending.push(msg.chatId, msg)
} catch (err) {
log.fail('intake', err, {
chatId: msg.chatId,
msgId: msg.messageId,
})
}
},
error: (err: unknown) => {
log.warn('ws', 'error', { err: String(err) })
},
reconnecting: () => {
log.warn('ws', 'reconnecting')
},
reconnected: () => {
log.info('ws', 'reconnected')
},
})
await channel.connect()
if (runId !== this.runId) return
this.channel = channel
this.pending = pending
this.chatModeCache = chatModeCache
// Keepalive — defense-in-depth against silent WS issues
this.keepalive = startKeepalive({
channel,
domain,
forceReconnect: async () => {
log.info('keepalive', 'reconnect-requested')
await channel.disconnect()
await channel.connect()
},
})
// Owner refresh — periodically fetches app owner from Feishu API
this.ownerRefresh = createOwnerRefreshController({
controls: this.controls,
source: channel,
appId: config.appId ?? '(unknown)',
})
await this.ownerRefresh.start()
const botName = channel.botIdentity?.name ?? config.appId
log.info('ws', 'connected', {
bot: botName,
openId: channel.botIdentity?.openId,
appId: config.appId,
})
this.setState({
status: 'running',
appId: config.appId,
botName,
ownerId: this.controls.botOwnerId,
})
} catch (err) {
if (runId !== this.runId) return
const msg = err instanceof Error ? err.message : String(err)
log.fail('start', err, { appId: config.appId })
this.setState({ status: 'error', appId: config.appId, lastError: msg })
throw err
}
}
async startFromSavedConfig(): Promise<void> {
const config = getFeishuConfig()
if (!config.appId || !config.appSecret) {
throw new Error('飞书 App ID 或 App Secret 未配置。')
}
await this.start(config)
}
async runRegistrationWizard(): Promise<{ appId: string; appSecret: string }> {
const result = await registerApp({
source: 'codev',
onQRCodeReady: info => {
log.info('wizard', 'qr-ready', {
url: info.url,
expireIn: info.expireIn,
})
},
onStatusChange: info => {
log.info('wizard', 'status', { status: info.status })
},
})
log.info('wizard', 'complete', { appId: result.client_id })
saveFeishuConfig({
appId: result.client_id,
appSecret: result.client_secret,
})
return {
appId: result.client_id,
appSecret: result.client_secret,
}
}
async stop(): Promise<void> {
this.runId++
if (this.ownerRefresh) {
this.ownerRefresh.stop()
this.ownerRefresh = undefined
}
if (this.keepalive) {
this.keepalive.stop()
this.keepalive = undefined
}
if (this.pending) {
this.pending.cancelAll()
this.pending = undefined
}
if (this.channel) {
try {
await this.channel.disconnect()
} catch {}
this.channel = undefined
}
this.chatModeCache = undefined
this.config = undefined
this.controls = new FeishuRuntimeControls()
if (this.state.status !== 'stopped') {
this.setState({ status: 'stopped' })
}
}
async sendText(chatId: string, text: string): Promise<void> {
if (!this.channel) return
try {
await this.channel.send(chatId, { text })
} catch (err) {
log.fail('send', err, { chatId, textLen: text.length })
}
}
async sendMarkdown(chatId: string, markdown: string): Promise<void> {
if (!this.channel) return
try {
const { optimizeMarkdownForFeishu } = await import(
'../../utils/feishuMarkdown.js'
)
const optimized = optimizeMarkdownForFeishu(markdown).replace(/<br\s*\/?>/gi, '\n')
await this.channel.send(chatId, { markdown: optimized })
} catch (err) {
log.fail('send', err, { chatId, markdownLen: markdown.length })
}
}
async sendVoice(chatId: string, text: string): Promise<void> {
if (!this.channel) return
try {
const config = getFeishuConfig()
if (!config.ttsEnabled) return
const { readFile, unlink, writeFile } = await import('node:fs/promises')
const { spawn } = await import('node:child_process')
const provider = config.ttsProvider || 'edge'
if (provider === 'voxcpm') {
const refAudio = config.ttsReferenceAudio
if (!refAudio) return
const chunks = this.splitTextForVoxCpm(text)
console.log('[feishu][tts] voxcpm: splitting into', chunks.length, 'chunks')
const timestamp = Date.now()
const rawPaths: string[] = []
const oggPaths: string[] = []
// Generate each chunk
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!.trim()
if (!chunk) continue
console.log('[feishu][tts] voxcpm: synthesizing chunk', i + 1, '/', chunks.length, 'len:', chunk.length)
const rawPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_${i}.wav`)
const oggPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_${i}.ogg`)
rawPaths.push(rawPath)
oggPaths.push(oggPath)
try {
await new Promise<void>((resolve, reject) => {
const child = spawn(
'/home/yuki/Code/Agent/Codev/.venv/bin/voxcpm',
['clone', '--text', chunk, '--reference-audio', refAudio, '--denoise', '--output', rawPath],
{ shell: false },
)
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`voxcpm exit ${code}`)) })
child.on('error', reject)
})
// Convert each chunk WAV → OGG (so concat can mix sample rates properly)
await new Promise<void>((resolve, reject) => {
const child = spawn('ffmpeg', ['-i', rawPath, '-c:a', 'libopus', '-b:a', '128k', '-y', oggPath])
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)) })
child.on('error', reject)
})
} catch (err) {
console.log('[feishu][tts] voxcpm: chunk', i + 1, 'failed, skipping:', err instanceof Error ? err.message : String(err))
rawPaths.pop()
oggPaths.pop()
}
}
if (rawPaths.length === 0) {
console.log('[feishu][tts] voxcpm: no chunks succeeded')
return
}
// Write concat list for ffmpeg
const listPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_list.txt`)
const listContent = oggPaths.map(p => `file '${p}'`).join('\n')
await writeFile(listPath, listContent)
// Concat all OGG chunks into one
const mergedOggPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_merged.ogg`)
await new Promise<void>((resolve, reject) => {
const child = spawn('ffmpeg', ['-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', '-y', mergedOggPath])
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`ffmpeg concat exit ${code}`)) })
child.on('error', reject)
})
const buffer = await readFile(mergedOggPath)
await this.channel.send(chatId, { audio: { source: buffer } })
console.log('[feishu][tts] voxcpm: merged audio sent, size:', buffer.length)
// Clean up
for (const p of [...rawPaths, ...oggPaths, listPath, mergedOggPath]) {
await unlink(p).catch(() => {})
}
return
}
// Edge TTS — single shot
const voice = config.ttsVoice || 'zh-CN-XiaoxiaoNeural'
const timestamp = Date.now()
const rawPath = path.join(os.tmpdir(), `feishu_tts_${timestamp}`)
const oggPath = path.join(os.tmpdir(), `feishu_tts_${timestamp}.ogg`)
await new Promise<void>((resolve, reject) => {
const child = spawn('edge-tts', ['--voice', voice, '--text', text, '--write-media', rawPath])
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`edge-tts exit ${code}`)) })
child.on('error', reject)
})
await new Promise<void>((resolve, reject) => {
const child = spawn('ffmpeg', ['-i', rawPath, '-c:a', 'libopus', '-b:a', '128k', '-y', oggPath])
child.on('close', code => { code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)) })
child.on('error', reject)
})
const buffer = await readFile(oggPath)
await this.channel.send(chatId, { audio: { source: buffer } })
await unlink(rawPath).catch(() => {})
await unlink(oggPath).catch(() => {})
} catch (err) {
console.log('[feishu][tts] send-voice-failed:', err instanceof Error ? err.message : String(err))
}
}
/**
* Split text into VoxCPM-friendly chunks (max ~150 chars each).
* Split at Chinese/English sentence boundaries to keep grammar intact.
*/
private splitTextForVoxCpm(text: string): string[] {
const sentences: string[] = []
const parts = text.split(/(?<=[。!?…\n])/)
let current = ''
for (const p of parts) {
const trimmed = p.trim()
if (!trimmed) continue
if (current.length + trimmed.length <= 150) {
current += (current ? ' ' : '') + trimmed
} else {
if (current) sentences.push(current)
current = trimmed
}
}
if (current) sentences.push(current)
return sentences
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onMessagesChange(_messages: unknown[], _isLoading: boolean): void {
// No-op
}
// ── Internal ────────────────────────────────
private setState(next: Partial<FeishuServiceState>): void {
this.state = { ...this.state, ...next }
for (const listener of this.listeners) listener()
}
}
export const feishuService = new FeishuService()