|
@@ -0,0 +1,832 @@
|
|
|
|
|
+import http from 'node:http'
|
|
|
|
|
+import { randomUUID } from 'node:crypto'
|
|
|
|
|
+import fs from 'node:fs'
|
|
|
|
|
+import path from 'node:path'
|
|
|
|
|
+import { fileURLToPath } from 'node:url'
|
|
|
|
|
+
|
|
|
|
|
+import * as Lark from '@larksuiteoapi/node-sdk'
|
|
|
|
|
+import { registerOapiTools } from '../tools/oapi/index.js'
|
|
|
|
|
+import { registerFeishuMcpDocTools } from '../tools/mcp/doc/index.js'
|
|
|
|
|
+import { withTicket, type LarkTicket } from '../core/lark-ticket.js'
|
|
|
|
|
+import { ToolClient } from '../core/tool-client.js'
|
|
|
|
|
+import { getStoredToken, setStoredToken, type StoredUAToken } from '../core/token-store.js'
|
|
|
|
|
+import { callWithUAT, NeedAuthorizationError } from '../core/uat-client.js'
|
|
|
|
|
+import { UserAuthRequiredError } from '../core/auth-errors.js'
|
|
|
|
|
+import { getLarkAccount, getEnabledLarkAccounts } from '../core/accounts.js'
|
|
|
|
|
+import { LarkClient } from '../core/lark-client.js'
|
|
|
|
|
+import { MessageDedup, isMessageExpired } from '../messaging/inbound/dedup.js'
|
|
|
|
|
+import type { FeishuMessageEvent, FeishuReactionCreatedEvent } from '../messaging/types.js'
|
|
|
|
|
+import { extractRawTextFromEvent } from '../channel/abort-detect.js'
|
|
|
|
|
+import { sendTextLark, sendCardLark, sendMediaLark } from '../messaging/outbound/deliver.js'
|
|
|
|
|
+import { addReactionFeishu, removeReactionFeishu, listReactionsFeishu } from '../messaging/outbound/reactions.js'
|
|
|
|
|
+import { requestDeviceAuthorization, pollDeviceToken } from '../core/device-flow.js'
|
|
|
|
|
+import { buildAuthCard } from '../tools/oauth-cards.js'
|
|
|
|
|
+import type { LarkBrand } from '../core/types.js'
|
|
|
|
|
+import YAML from 'yaml'
|
|
|
|
|
+
|
|
|
|
|
+interface RegisteredTool {
|
|
|
|
|
+ name: string
|
|
|
|
|
+ label?: string
|
|
|
|
|
+ description?: string
|
|
|
|
|
+ parameters?: unknown
|
|
|
|
|
+ execute: (toolCallId: string, params: unknown) => Promise<unknown>
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Logger {
|
|
|
|
|
+ info?: (msg: string) => void
|
|
|
|
|
+ warn?: (msg: string) => void
|
|
|
|
|
+ error?: (msg: string) => void
|
|
|
|
|
+ debug?: (msg: string) => void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ToolCallRequest {
|
|
|
|
|
+ tool: string
|
|
|
|
|
+ params?: unknown
|
|
|
|
|
+ tool_call_id?: string
|
|
|
|
|
+ context?: {
|
|
|
|
|
+ message_id?: string
|
|
|
|
|
+ chat_id?: string
|
|
|
|
|
+ account_id?: string
|
|
|
|
|
+ sender_open_id?: string
|
|
|
|
|
+ chat_type?: 'p2p' | 'group'
|
|
|
|
|
+ thread_id?: string
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ToolCallResponse {
|
|
|
|
|
+ ok: boolean
|
|
|
|
|
+ result?: unknown
|
|
|
|
|
+ error?: string
|
|
|
|
|
+ details?: unknown
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const tools = new Map<string, RegisteredTool>()
|
|
|
|
|
+const globalConfigRef: { cfg: unknown | null } = { cfg: null }
|
|
|
|
|
+
|
|
|
|
|
+const logger: Logger = {
|
|
|
|
|
+ info: (msg: string) => console.log(msg),
|
|
|
|
|
+ warn: (msg: string) => console.warn(msg),
|
|
|
|
|
+ error: (msg: string) => console.error(msg),
|
|
|
|
|
+ debug: (msg: string) => console.debug(msg),
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const GATEWAY_FEISHU_WEBHOOK_URL =
|
|
|
|
|
+ process.env.GATEWAY_FEISHU_WEBHOOK_URL ??
|
|
|
|
|
+ 'http://localhost:8000/api/channels/feishu/openclaw/webhook'
|
|
|
|
|
+
|
|
|
|
|
+function patchToolClientOwnerCheck() {
|
|
|
|
|
+ const proto: any = (ToolClient as any).prototype
|
|
|
|
|
+ const original = proto.invokeAsUser
|
|
|
|
|
+ if (!original || (original as any).__patchedByHttpServer) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ proto.invokeAsUser = async function (
|
|
|
|
|
+ toolAction: unknown,
|
|
|
|
|
+ fn: (sdk: unknown, opts?: unknown, uat?: string) => Promise<unknown>,
|
|
|
|
|
+ requiredScopes: string[],
|
|
|
|
|
+ userOpenId: string | undefined,
|
|
|
|
|
+ appScopeVerified: boolean,
|
|
|
|
|
+ ): Promise<unknown> {
|
|
|
|
|
+ if (!userOpenId) {
|
|
|
|
|
+ throw new UserAuthRequiredError('unknown', {
|
|
|
|
|
+ apiName: toolAction as any,
|
|
|
|
|
+ scopes: requiredScopes,
|
|
|
|
|
+ appScopeVerified,
|
|
|
|
|
+ appId: (this as any).account.appId,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const stored = await getStoredToken((this as any).account.appId, userOpenId)
|
|
|
|
|
+ if (!stored) {
|
|
|
|
|
+ throw new UserAuthRequiredError(userOpenId, {
|
|
|
|
|
+ apiName: toolAction as any,
|
|
|
|
|
+ scopes: requiredScopes,
|
|
|
|
|
+ appScopeVerified,
|
|
|
|
|
+ appId: (this as any).account.appId,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (appScopeVerified && stored.scope && requiredScopes.length > 0) {
|
|
|
|
|
+ const userGrantedScopes = new Set<string>(stored.scope.split(/\s+/).filter(Boolean))
|
|
|
|
|
+ const missingUserScopes = requiredScopes.filter((s) => !userGrantedScopes.has(s))
|
|
|
|
|
+ if (missingUserScopes.length > 0) {
|
|
|
|
|
+ throw new UserAuthRequiredError(userOpenId, {
|
|
|
|
|
+ apiName: toolAction as any,
|
|
|
|
|
+ scopes: missingUserScopes,
|
|
|
|
|
+ appScopeVerified,
|
|
|
|
|
+ appId: (this as any).account.appId,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await callWithUAT(
|
|
|
|
|
+ {
|
|
|
|
|
+ userOpenId,
|
|
|
|
|
+ appId: (this as any).account.appId,
|
|
|
|
|
+ appSecret: (this as any).account.appSecret,
|
|
|
|
|
+ domain: (this as any).account.brand,
|
|
|
|
|
+ },
|
|
|
|
|
+ (accessToken: string) =>
|
|
|
|
|
+ fn((this as any).sdk, Lark.withUserAccessToken(accessToken), accessToken),
|
|
|
|
|
+ )
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ if (err instanceof NeedAuthorizationError) {
|
|
|
|
|
+ throw new UserAuthRequiredError(userOpenId, {
|
|
|
|
|
+ apiName: toolAction as any,
|
|
|
|
|
+ scopes: requiredScopes,
|
|
|
|
|
+ appScopeVerified,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ ; (this as any).rethrowStructuredError(
|
|
|
|
|
+ err,
|
|
|
|
|
+ toolAction,
|
|
|
|
|
+ requiredScopes,
|
|
|
|
|
+ userOpenId,
|
|
|
|
|
+ 'user',
|
|
|
|
|
+ )
|
|
|
|
|
+ throw err
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ; (proto.invokeAsUser as any).__patchedByHttpServer = true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resolveConfigPath() {
|
|
|
|
|
+ const cwdConfig = path.resolve(process.cwd(), 'config.yml')
|
|
|
|
|
+ if (fs.existsSync(cwdConfig)) return cwdConfig
|
|
|
|
|
+ const srcConfig = path.resolve(process.cwd(), 'src/http/config.yml')
|
|
|
|
|
+ if (fs.existsSync(srcConfig)) return srcConfig
|
|
|
|
|
+ const filename = fileURLToPath(import.meta.url)
|
|
|
|
|
+ const dirname = path.dirname(filename)
|
|
|
|
|
+ const localConfig = path.join(dirname, 'config.yml')
|
|
|
|
|
+ return localConfig
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function createConfig() {
|
|
|
|
|
+ const configPath = resolveConfigPath()
|
|
|
|
|
+ const raw = fs.readFileSync(configPath, 'utf8')
|
|
|
|
|
+ const parsed = YAML.parse(raw) as { feishu?: unknown }
|
|
|
|
|
+ if (!parsed || !parsed.feishu) {
|
|
|
|
|
+ throw new Error('config.yml missing "feishu" section')
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ channels: {
|
|
|
|
|
+ feishu: parsed.feishu,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function createApi(config: unknown, apiLogger: Logger) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ config,
|
|
|
|
|
+ logger: apiLogger,
|
|
|
|
|
+ registerTool(def: RegisteredTool) {
|
|
|
|
|
+ tools.set(def.name, def)
|
|
|
|
|
+ apiLogger.info?.(`Registered tool: ${def.name}`)
|
|
|
|
|
+ },
|
|
|
|
|
+ on() { },
|
|
|
|
|
+ registerChannel() { },
|
|
|
|
|
+ registerCli() { },
|
|
|
|
|
+ } as any
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function initTools() {
|
|
|
|
|
+ const config = createConfig()
|
|
|
|
|
+ globalConfigRef.cfg = config
|
|
|
|
|
+ const api = createApi(config, logger)
|
|
|
|
|
+
|
|
|
|
|
+ patchToolClientOwnerCheck()
|
|
|
|
|
+
|
|
|
|
|
+ registerOapiTools(api)
|
|
|
|
|
+ registerFeishuMcpDocTools(api)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const chunks: Buffer[] = []
|
|
|
|
|
+ req.on('data', (chunk: Buffer | string) => {
|
|
|
|
|
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
|
|
|
|
+ })
|
|
|
|
|
+ req.on('end', () => {
|
|
|
|
|
+ if (chunks.length === 0) {
|
|
|
|
|
+ resolve({})
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const raw = Buffer.concat(chunks).toString('utf8')
|
|
|
|
|
+ try {
|
|
|
|
|
+ resolve(JSON.parse(raw))
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ reject(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ req.on('error', (err: unknown) => {
|
|
|
|
|
+ reject(err)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function sendJson(res: http.ServerResponse, statusCode: number, body: unknown) {
|
|
|
|
|
+ const payload = JSON.stringify(body)
|
|
|
|
|
+ res.statusCode = statusCode
|
|
|
|
|
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
|
|
|
+ res.setHeader('Content-Length', Buffer.byteLength(payload))
|
|
|
|
|
+ res.end(payload)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toToolCallRequest(body: unknown): ToolCallRequest {
|
|
|
|
|
+ if (!body || typeof body !== 'object') {
|
|
|
|
|
+ throw new Error('Request body must be an object')
|
|
|
|
|
+ }
|
|
|
|
|
+ const obj = body as Record<string, unknown>
|
|
|
|
|
+ const tool = obj.tool
|
|
|
|
|
+ if (!tool || typeof tool !== 'string') {
|
|
|
|
|
+ throw new Error('Missing field: tool')
|
|
|
|
|
+ }
|
|
|
|
|
+ const params = obj.params
|
|
|
|
|
+ const toolCallId =
|
|
|
|
|
+ typeof obj.tool_call_id === 'string' ? obj.tool_call_id : randomUUID()
|
|
|
|
|
+ const context =
|
|
|
|
|
+ obj.context && typeof obj.context === 'object'
|
|
|
|
|
+ ? (obj.context as ToolCallRequest['context'])
|
|
|
|
|
+ : {}
|
|
|
|
|
+ return { tool, params, tool_call_id: toolCallId, context }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildTicket(req: ToolCallRequest): LarkTicket {
|
|
|
|
|
+ const ctx = req.context ?? {}
|
|
|
|
|
+ return {
|
|
|
|
|
+ messageId: ctx.message_id ?? req.tool_call_id ?? randomUUID(),
|
|
|
|
|
+ chatId: ctx.chat_id ?? ctx.thread_id ?? 'http',
|
|
|
|
|
+ accountId: ctx.account_id ?? 'default',
|
|
|
|
|
+ startTime: Date.now(),
|
|
|
|
|
+ senderOpenId: ctx.sender_open_id,
|
|
|
|
|
+ chatType: ctx.chat_type,
|
|
|
|
|
+ threadId: ctx.thread_id,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function triggerOAuthDeviceFlow(params: {
|
|
|
|
|
+ cfg: unknown
|
|
|
|
|
+ ticket: LarkTicket
|
|
|
|
|
+ accountId: string
|
|
|
|
|
+ senderOpenId: string
|
|
|
|
|
+ requiredScopes: string[]
|
|
|
|
|
+}): Promise<void> {
|
|
|
|
|
+ const { cfg, ticket, accountId, senderOpenId, requiredScopes } = params
|
|
|
|
|
+ const account = getLarkAccount(cfg as any, accountId)
|
|
|
|
|
+ if (!account.configured) {
|
|
|
|
|
+ logger.warn?.(`OAuth device flow skipped: account ${accountId} not configured`)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const scope = requiredScopes.join(' ')
|
|
|
|
|
+ const brand = account.brand as LarkBrand
|
|
|
|
|
+
|
|
|
|
|
+ let deviceAuth
|
|
|
|
|
+ try {
|
|
|
|
|
+ deviceAuth = await requestDeviceAuthorization({
|
|
|
|
|
+ appId: account.appId,
|
|
|
|
|
+ appSecret: account.appSecret,
|
|
|
|
|
+ brand,
|
|
|
|
|
+ scope,
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ logger.error?.(`Failed to start OAuth device flow: ${msg}`)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const chatId = ticket.chatId
|
|
|
|
|
+ if (chatId) {
|
|
|
|
|
+ const expiresMin = Math.round(deviceAuth.expiresIn / 60)
|
|
|
|
|
+ const card = buildAuthCard({
|
|
|
|
|
+ verificationUriComplete: deviceAuth.verificationUriComplete,
|
|
|
|
|
+ expiresMin,
|
|
|
|
|
+ scope,
|
|
|
|
|
+ brand,
|
|
|
|
|
+ appId: account.appId,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await sendCardLark({
|
|
|
|
|
+ cfg: cfg as any,
|
|
|
|
|
+ to: chatId,
|
|
|
|
|
+ card,
|
|
|
|
|
+ replyToMessageId: ticket.messageId?.startsWith('om_') ? ticket.messageId : undefined,
|
|
|
|
|
+ replyInThread: Boolean(ticket.threadId),
|
|
|
|
|
+ accountId,
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ logger.error?.(`Failed to send OAuth card: ${msg}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void (async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await pollDeviceToken({
|
|
|
|
|
+ appId: account.appId,
|
|
|
|
|
+ appSecret: account.appSecret,
|
|
|
|
|
+ brand,
|
|
|
|
|
+ deviceCode: deviceAuth.deviceCode,
|
|
|
|
|
+ interval: deviceAuth.interval,
|
|
|
|
|
+ expiresIn: deviceAuth.expiresIn,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ logger.warn?.(
|
|
|
|
|
+ `OAuth device flow failed for user=${senderOpenId}, app=${account.appId}: ${res.error} ${res.message}`,
|
|
|
|
|
+ )
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const now = Date.now()
|
|
|
|
|
+ const stored: StoredUAToken = {
|
|
|
|
|
+ userOpenId: senderOpenId,
|
|
|
|
|
+ appId: account.appId,
|
|
|
|
|
+ accessToken: res.token.accessToken,
|
|
|
|
|
+ refreshToken: res.token.refreshToken,
|
|
|
|
|
+ expiresAt: now + res.token.expiresIn * 1000,
|
|
|
|
|
+ refreshExpiresAt: now + res.token.refreshExpiresIn * 1000,
|
|
|
|
|
+ scope: res.token.scope,
|
|
|
|
|
+ grantedAt: now,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await setStoredToken(stored)
|
|
|
|
|
+ logger.info?.(
|
|
|
|
|
+ `OAuth device flow completed, token stored for user=${senderOpenId}, app=${account.appId}`,
|
|
|
|
|
+ )
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ logger.error?.(`OAuth device flow polling error: ${msg}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ })()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleToolCall(body: unknown): Promise<ToolCallResponse> {
|
|
|
|
|
+ let req: ToolCallRequest
|
|
|
|
|
+ try {
|
|
|
|
|
+ req = toToolCallRequest(body)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ return { ok: false, error: msg }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const tool = tools.get(req.tool)
|
|
|
|
|
+ if (!tool) {
|
|
|
|
|
+ return { ok: false, error: `Tool not found: ${req.tool}` }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const ticket = buildTicket(req)
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await withTicket(ticket, () =>
|
|
|
|
|
+ tool.execute(req.tool_call_id ?? randomUUID(), req.params),
|
|
|
|
|
+ )
|
|
|
|
|
+ return { ok: true, result }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ if (err instanceof UserAuthRequiredError) {
|
|
|
|
|
+ logger.warn?.(
|
|
|
|
|
+ `Tool call requires user authorization: ${req.tool}, user=${err.userOpenId}, scopes=${err.requiredScopes.join(
|
|
|
|
|
+ ',',
|
|
|
|
|
+ )}`,
|
|
|
|
|
+ )
|
|
|
|
|
+ const cfg = globalConfigRef.cfg
|
|
|
|
|
+ if (!cfg) {
|
|
|
|
|
+ return { ok: false, error: 'need_user_authorization', details: { error: 'missing_config' } }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void triggerOAuthDeviceFlow({
|
|
|
|
|
+ cfg,
|
|
|
|
|
+ ticket,
|
|
|
|
|
+ accountId: ticket.accountId,
|
|
|
|
|
+ senderOpenId: err.userOpenId,
|
|
|
|
|
+ requiredScopes: err.requiredScopes,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: false,
|
|
|
|
|
+ error: 'need_user_authorization',
|
|
|
|
|
+ details: {
|
|
|
|
|
+ api: err.apiName,
|
|
|
|
|
+ required_scopes: err.requiredScopes,
|
|
|
|
|
+ app_id: err.appId,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ logger.error?.(`Tool call failed: ${req.tool}: ${msg}`)
|
|
|
|
|
+ return { ok: false, error: msg }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleSendFeishuMessage(body: unknown): Promise<ToolCallResponse> {
|
|
|
|
|
+ if (!body || typeof body !== 'object') {
|
|
|
|
|
+ return { ok: false, error: 'Request body must be an object' }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const cfg = globalConfigRef.cfg
|
|
|
|
|
+ if (!cfg) {
|
|
|
|
|
+ return { ok: false, error: 'config_not_initialised' }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const obj = body as Record<string, unknown>
|
|
|
|
|
+ const kind = (obj.kind as string | undefined) ?? 'text'
|
|
|
|
|
+ const accountId = typeof obj.account_id === 'string' ? obj.account_id : undefined
|
|
|
|
|
+ const chatId = typeof obj.chat_id === 'string' ? obj.chat_id : undefined
|
|
|
|
|
+ const openId = typeof obj.open_id === 'string' ? obj.open_id : undefined
|
|
|
|
|
+ const explicitTo = typeof obj.to === 'string' ? obj.to : undefined
|
|
|
|
|
+ const replyToMessageId =
|
|
|
|
|
+ typeof obj.reply_to_message_id === 'string' ? obj.reply_to_message_id : undefined
|
|
|
|
|
+ const replyInThread =
|
|
|
|
|
+ typeof obj.reply_in_thread === 'boolean' ? obj.reply_in_thread : undefined
|
|
|
|
|
+
|
|
|
|
|
+ const to =
|
|
|
|
|
+ explicitTo ??
|
|
|
|
|
+ chatId ??
|
|
|
|
|
+ openId ??
|
|
|
|
|
+ ''
|
|
|
|
|
+
|
|
|
|
|
+ if (!to) {
|
|
|
|
|
+ return { ok: false, error: 'missing_target', details: { message: 'to/chat_id/open_id is required' } }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ switch (kind) {
|
|
|
|
|
+ case 'text': {
|
|
|
|
|
+ const text = typeof obj.text === 'string' ? obj.text : undefined
|
|
|
|
|
+ if (!text) {
|
|
|
|
|
+ return { ok: false, error: 'missing_text' }
|
|
|
|
|
+ }
|
|
|
|
|
+ const result = await sendTextLark({
|
|
|
|
|
+ cfg: cfg as any,
|
|
|
|
|
+ to,
|
|
|
|
|
+ text,
|
|
|
|
|
+ replyToMessageId,
|
|
|
|
|
+ replyInThread,
|
|
|
|
|
+ accountId,
|
|
|
|
|
+ })
|
|
|
|
|
+ return { ok: true, result }
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'card': {
|
|
|
|
|
+ const card = obj.card as Record<string, unknown> | undefined
|
|
|
|
|
+ if (!card || typeof card !== 'object') {
|
|
|
|
|
+ return { ok: false, error: 'missing_card' }
|
|
|
|
|
+ }
|
|
|
|
|
+ const result = await sendCardLark({
|
|
|
|
|
+ cfg: cfg as any,
|
|
|
|
|
+ to,
|
|
|
|
|
+ card,
|
|
|
|
|
+ replyToMessageId,
|
|
|
|
|
+ replyInThread,
|
|
|
|
|
+ accountId,
|
|
|
|
|
+ })
|
|
|
|
|
+ return { ok: true, result }
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'media': {
|
|
|
|
|
+ const mediaUrl = typeof obj.media_url === 'string' ? obj.media_url : undefined
|
|
|
|
|
+ if (!mediaUrl) {
|
|
|
|
|
+ return { ok: false, error: 'missing_media_url' }
|
|
|
|
|
+ }
|
|
|
|
|
+ const mediaLocalRoots = Array.isArray(obj.media_local_roots)
|
|
|
|
|
+ ? (obj.media_local_roots as string[])
|
|
|
|
|
+ : undefined
|
|
|
|
|
+ const result = await sendMediaLark({
|
|
|
|
|
+ cfg: cfg as any,
|
|
|
|
|
+ to,
|
|
|
|
|
+ mediaUrl,
|
|
|
|
|
+ replyToMessageId,
|
|
|
|
|
+ replyInThread,
|
|
|
|
|
+ accountId,
|
|
|
|
|
+ mediaLocalRoots,
|
|
|
|
|
+ })
|
|
|
|
|
+ return { ok: true, result }
|
|
|
|
|
+ }
|
|
|
|
|
+ default:
|
|
|
|
|
+ return { ok: false, error: 'unsupported_kind', details: { kind } }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ return { ok: false, error: msg }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleFeishuReaction(body: unknown): Promise<ToolCallResponse> {
|
|
|
|
|
+ if (!body || typeof body !== 'object') {
|
|
|
|
|
+ return { ok: false, error: 'Request body must be an object' }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const cfg = globalConfigRef.cfg
|
|
|
|
|
+ if (!cfg) {
|
|
|
|
|
+ return { ok: false, error: 'config_not_initialised' }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const obj = body as Record<string, unknown>
|
|
|
|
|
+ const action = (obj.action as string | undefined) ?? 'add'
|
|
|
|
|
+ const accountId = typeof obj.account_id === 'string' ? obj.account_id : undefined
|
|
|
|
|
+ const messageId = typeof obj.message_id === 'string' ? obj.message_id : undefined
|
|
|
|
|
+ const emoji = typeof obj.emoji === 'string' ? obj.emoji : undefined
|
|
|
|
|
+
|
|
|
|
|
+ if (!messageId) {
|
|
|
|
|
+ return { ok: false, error: 'missing_message_id' }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (action === 'list') {
|
|
|
|
|
+ const reactions = await listReactionsFeishu({
|
|
|
|
|
+ cfg: cfg as any,
|
|
|
|
|
+ messageId,
|
|
|
|
|
+ emojiType: emoji || undefined,
|
|
|
|
|
+ accountId,
|
|
|
|
|
+ })
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ result: {
|
|
|
|
|
+ reactions: reactions.map((r) => ({
|
|
|
|
|
+ reactionId: r.reactionId,
|
|
|
|
|
+ emoji: r.emojiType,
|
|
|
|
|
+ operatorType: r.operatorType,
|
|
|
|
|
+ operatorId: r.operatorId,
|
|
|
|
|
+ })),
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (action === 'remove') {
|
|
|
|
|
+ const reactions = await listReactionsFeishu({
|
|
|
|
|
+ cfg: cfg as any,
|
|
|
|
|
+ messageId,
|
|
|
|
|
+ emojiType: emoji || undefined,
|
|
|
|
|
+ accountId,
|
|
|
|
|
+ })
|
|
|
|
|
+ const botReactions = reactions.filter((r) => r.operatorType === 'app')
|
|
|
|
|
+ for (const r of botReactions) {
|
|
|
|
|
+ await removeReactionFeishu({
|
|
|
|
|
+ cfg: cfg as any,
|
|
|
|
|
+ messageId,
|
|
|
|
|
+ reactionId: r.reactionId,
|
|
|
|
|
+ accountId,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ result: {
|
|
|
|
|
+ removed: botReactions.length,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!emoji) {
|
|
|
|
|
+ return { ok: false, error: 'missing_emoji' }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { reactionId } = await addReactionFeishu({
|
|
|
|
|
+ cfg: cfg as any,
|
|
|
|
|
+ messageId,
|
|
|
|
|
+ emojiType: emoji,
|
|
|
|
|
+ accountId,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ result: {
|
|
|
|
|
+ reactionId,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ return { ok: false, error: msg }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function forwardEventToGateway(normalized: unknown) {
|
|
|
|
|
+ return new Promise<void>((resolve, reject) => {
|
|
|
|
|
+ const req = http.request(
|
|
|
|
|
+ GATEWAY_FEISHU_WEBHOOK_URL,
|
|
|
|
|
+ {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ },
|
|
|
|
|
+ (res) => {
|
|
|
|
|
+ res.on('data', () => { })
|
|
|
|
|
+ res.on('end', () => resolve())
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ req.on('error', (err) => {
|
|
|
|
|
+ reject(err)
|
|
|
|
|
+ })
|
|
|
|
|
+ req.end(JSON.stringify(normalized))
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function startFeishuLongConnections() {
|
|
|
|
|
+ const cfg = globalConfigRef.cfg
|
|
|
|
|
+ if (!cfg) {
|
|
|
|
|
+ logger.warn?.('feishu ws: missing config, skip long connections')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const accounts = getEnabledLarkAccounts(cfg as any)
|
|
|
|
|
+ if (accounts.length === 0) {
|
|
|
|
|
+ logger.warn?.('feishu ws: no enabled accounts, skip long connections')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const account of accounts) {
|
|
|
|
|
+ const mode = (account.config as any).connectionMode ?? 'websocket'
|
|
|
|
|
+ if (mode !== 'websocket') {
|
|
|
|
|
+ logger.info?.(`feishu[${account.accountId}]: connectionMode=${mode}, skip websocket`)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const dedup = new MessageDedup()
|
|
|
|
|
+ const lark = LarkClient.fromAccount(account)
|
|
|
|
|
+
|
|
|
|
|
+ void lark
|
|
|
|
|
+ .startWS({
|
|
|
|
|
+ handlers: {
|
|
|
|
|
+ 'im.message.receive_v1': async (data: unknown) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const event = data as FeishuMessageEvent
|
|
|
|
|
+ const msgId = event.message?.message_id
|
|
|
|
|
+ if (!msgId) return
|
|
|
|
|
+ if (!dedup.tryRecord(msgId, account.accountId)) {
|
|
|
|
|
+ logger.debug?.(`feishu[${account.accountId}]: duplicate message ${msgId}, skip`)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const text = extractRawTextFromEvent(event) ?? ''
|
|
|
|
|
+ const normalized = {
|
|
|
|
|
+ event_type: 'message',
|
|
|
|
|
+ tenant_id: event.sender?.tenant_key,
|
|
|
|
|
+ app_id: account.appId,
|
|
|
|
|
+ account_id: account.accountId,
|
|
|
|
|
+ open_id: event.sender?.sender_id?.open_id,
|
|
|
|
|
+ chat_type: event.message?.chat_type,
|
|
|
|
|
+ chat_id: event.message?.chat_id,
|
|
|
|
|
+ message_id: msgId,
|
|
|
|
|
+ content: text,
|
|
|
|
|
+ raw: event,
|
|
|
|
|
+ }
|
|
|
|
|
+ await forwardEventToGateway(normalized)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ logger.error?.(`feishu[${account.accountId}]: ws handler error: ${msg}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ // 'im.message.reaction.created_v1': async (data: unknown) => {
|
|
|
|
|
+ // try {
|
|
|
|
|
+ // const event = data as FeishuReactionCreatedEvent
|
|
|
|
|
+ // const msgId = event.message_id
|
|
|
|
|
+ // const emojiType = event.reaction_type?.emoji_type ?? ''
|
|
|
|
|
+ // const operatorOpenId = event.user_id?.open_id ?? ''
|
|
|
|
|
+ // if (!msgId || !emojiType || !operatorOpenId) return
|
|
|
|
|
+
|
|
|
|
|
+ // const dedupKey = `${msgId}:reaction:${emojiType}:${operatorOpenId}`
|
|
|
|
|
+ // if (!dedup.tryRecord(dedupKey, account.accountId)) {
|
|
|
|
|
+ // logger.debug?.(`feishu[${account.accountId}]: duplicate reaction ${dedupKey}, skip`)
|
|
|
|
|
+ // return
|
|
|
|
|
+ // }
|
|
|
|
|
+
|
|
|
|
|
+ // if (isMessageExpired(event.action_time)) {
|
|
|
|
|
+ // logger.debug?.(`feishu[${account.accountId}]: reaction on ${msgId} expired, skip`)
|
|
|
|
|
+ // return
|
|
|
|
|
+ // }
|
|
|
|
|
+
|
|
|
|
|
+ // const normalized = {
|
|
|
|
|
+ // event_type: 'reaction',
|
|
|
|
|
+ // app_id: account.appId,
|
|
|
|
|
+ // account_id: account.accountId,
|
|
|
|
|
+ // open_id: operatorOpenId,
|
|
|
|
|
+ // chat_type: event.chat_type,
|
|
|
|
|
+ // chat_id: event.chat_id,
|
|
|
|
|
+ // message_id: msgId,
|
|
|
|
|
+ // emoji: emojiType,
|
|
|
|
|
+ // action_time: event.action_time,
|
|
|
|
|
+ // raw: data,
|
|
|
|
|
+ // }
|
|
|
|
|
+ // await forwardEventToGateway(normalized)
|
|
|
|
|
+ // } catch (err) {
|
|
|
|
|
+ // const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ // logger.error?.(`feishu[${account.accountId}]: ws reaction event error: ${msg}`)
|
|
|
|
|
+ // }
|
|
|
|
|
+ // },
|
|
|
|
|
+ 'card.action.trigger': async (data: unknown) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const event = data as {
|
|
|
|
|
+ operator?: { open_id?: string }
|
|
|
|
|
+ action?: { value?: { action?: string; operation_id?: string } }
|
|
|
|
|
+ }
|
|
|
|
|
+ const openId = event.operator?.open_id
|
|
|
|
|
+ const action = event.action?.value?.action
|
|
|
|
|
+ const operationId = event.action?.value?.operation_id
|
|
|
|
|
+ const normalized = {
|
|
|
|
|
+ event_type: 'card_action',
|
|
|
|
|
+ app_id: account.appId,
|
|
|
|
|
+ account_id: account.accountId,
|
|
|
|
|
+ open_id: openId,
|
|
|
|
|
+ action,
|
|
|
|
|
+ operation_id: operationId,
|
|
|
|
|
+ raw: data,
|
|
|
|
|
+ }
|
|
|
|
|
+ await forwardEventToGateway(normalized)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ logger.error?.(`feishu[${account.accountId}]: ws card.action.trigger error: ${msg}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch((err) => {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ logger.error?.(`feishu[${account.accountId}]: failed to start websocket: ${msg}`)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ logger.info?.(`feishu[${account.accountId}]: websocket long connection started`)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function createServer() {
|
|
|
|
|
+ const port = Number.parseInt(process.env.FEISHU_HTTP_PORT ?? '4380', 10)
|
|
|
|
|
+
|
|
|
|
|
+ const server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
|
|
|
+ if (!req.url) {
|
|
|
|
|
+ sendJson(res, 400, { ok: false, error: 'Missing URL' })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (req.method === 'GET' && req.url === '/healthz') {
|
|
|
|
|
+ sendJson(res, 200, { ok: true })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (req.method === 'GET' && req.url === '/tools') {
|
|
|
|
|
+ const list = Array.from(tools.values()).map((t) => ({
|
|
|
|
|
+ name: t.name,
|
|
|
|
|
+ label: t.label,
|
|
|
|
|
+ description: t.description,
|
|
|
|
|
+ parameters: t.parameters,
|
|
|
|
|
+ }))
|
|
|
|
|
+ sendJson(res, 200, { ok: true, tools: list })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (req.method === 'POST' && req.url === '/tool-call') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const body = await readJsonBody(req)
|
|
|
|
|
+ const resp = await handleToolCall(body)
|
|
|
|
|
+ const status = resp.ok ? 200 : 400
|
|
|
|
|
+ sendJson(res, status, resp)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ sendJson(res, 500, { ok: false, error: msg })
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (req.method === 'POST' && req.url === '/feishu/send-message') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const body = await readJsonBody(req)
|
|
|
|
|
+ const resp = await handleSendFeishuMessage(body)
|
|
|
|
|
+ const status = resp.ok ? 200 : 400
|
|
|
|
|
+ sendJson(res, status, resp)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ sendJson(res, 500, { ok: false, error: msg })
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (req.method === 'POST' && req.url === '/feishu/react') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const body = await readJsonBody(req)
|
|
|
|
|
+ const resp = await handleFeishuReaction(body)
|
|
|
|
|
+ const status = resp.ok ? 200 : 400
|
|
|
|
|
+ sendJson(res, status, resp)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ sendJson(res, 500, { ok: false, error: msg })
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ sendJson(res, 404, { ok: false, error: 'Not Found' })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ server.listen(port, () => {
|
|
|
|
|
+ logger.info?.(`Feishu HTTP adapter listening on port ${port}`)
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+initTools()
|
|
|
|
|
+ .then(() => {
|
|
|
|
|
+ createServer()
|
|
|
|
|
+ void startFeishuLongConnections()
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch((err) => {
|
|
|
|
|
+ logger.error?.(
|
|
|
|
|
+ `Failed to initialise tools: ${err instanceof Error ? err.message : String(err)}`,
|
|
|
|
|
+ )
|
|
|
|
|
+ process.exitCode = 1
|
|
|
|
|
+ })
|