Add Feishu channel adapter with webhook and send path

This commit is contained in:
William Valentin
2026-02-16 13:07:45 -08:00
parent 376f74550e
commit 891ccb696e
16 changed files with 644 additions and 6 deletions
+156
View File
@@ -0,0 +1,156 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';
import { FeishuAdapter } from './adapter.js';
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
} as Response;
}
function mockReq(body: string): IncomingMessage {
const req = {
headers: {},
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'data') {
handler(Buffer.from(body, 'utf8'));
}
if (event === 'end') {
handler();
}
return this;
},
off: () => req,
destroy: () => undefined,
} as unknown as IncomingMessage;
return req;
}
function mockRes() {
const state = { statusCode: 0, body: '' };
const res = {
writeHead: (code: number) => {
state.statusCode = code;
},
end: (chunk?: string) => {
state.body = chunk ?? '';
},
} as unknown as ServerResponse;
return { res, state };
}
describe('FeishuAdapter', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockReset();
});
it('has name feishu and starts disconnected', () => {
const adapter = new FeishuAdapter({
appId: 'app-id',
appSecret: 'app-secret',
});
expect(adapter.name).toBe('feishu');
expect(adapter.status).toBe('disconnected');
});
it('send fetches token and posts message', async () => {
const adapter = new FeishuAdapter({
appId: 'app-id',
appSecret: 'app-secret',
});
await adapter.connect();
mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/tenant_access_token/internal')) {
return jsonResponse({ code: 0, tenant_access_token: 'tenant-token', expire: 7200 });
}
if (url.includes('/im/v1/messages?receive_id_type=chat_id')) {
return jsonResponse({ code: 0, msg: 'ok' });
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
await adapter.send('oc_xxx_chat', { text: 'hello feishu' });
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('handleRequest returns challenge for url verification', async () => {
const adapter = new FeishuAdapter({
appId: 'app-id',
appSecret: 'app-secret',
webhookToken: 'verify-token',
});
const body = JSON.stringify({
type: 'url_verification',
challenge: 'challenge-token',
});
const req = mockReq(body);
const { res, state } = mockRes();
await adapter.handleRequest(req, res);
expect(state.statusCode).toBe(200);
expect(state.body).toContain('challenge-token');
});
it('handleEvent forwards text message with reply metadata', async () => {
const adapter = new FeishuAdapter({
appId: 'app-id',
appSecret: 'app-secret',
webhookToken: 'verify-token',
requireMention: false,
});
const inbound: Array<{ channel: string; senderId: string; text: string }> = [];
adapter.onMessage((msg) => inbound.push({ channel: msg.channel, senderId: msg.senderId, text: msg.text }));
await adapter.handleEvent({
header: { event_type: 'im.message.receive_v1', token: 'verify-token' },
event: {
sender: { sender_id: { open_id: 'ou_123' } },
message: {
message_id: 'om_1',
chat_id: 'oc_123',
chat_type: 'group',
message_type: 'text',
content: JSON.stringify({ text: 'ping' }),
},
},
});
expect(inbound).toEqual([{ channel: 'feishu', senderId: 'ou_123', text: 'ping' }]);
});
it('enforces webhook token on event ingress', async () => {
const adapter = new FeishuAdapter({
appId: 'app-id',
appSecret: 'app-secret',
webhookToken: 'verify-token',
});
const body = JSON.stringify({
header: { event_type: 'im.message.receive_v1', token: 'wrong-token' },
event: {
sender: { sender_id: { open_id: 'ou_123' } },
message: {
message_id: 'om_1',
chat_id: 'oc_123',
chat_type: 'group',
message_type: 'text',
content: JSON.stringify({ text: 'ping' }),
},
},
});
const req = mockReq(body);
const { res, state } = mockRes();
await adapter.handleRequest(req, res);
expect(state.statusCode).toBe(401);
});
});
+269
View File
@@ -0,0 +1,269 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type {
InboundMessage,
OutboundMessage,
ChannelAdapter,
ChannelStatus,
} from '../types.js';
import { shouldIgnoreForMissingMention, splitMessage } from '../utils.js';
import { readRequestBody } from '../../utils/httpBody.js';
export interface FeishuAdapterConfig {
appId: string;
appSecret: string;
webhookToken?: string;
allowedChatIds?: string[];
requireMention?: boolean;
mentionName?: string;
endpoint?: string;
}
interface FeishuTenantTokenResponse {
code?: number;
tenant_access_token?: string;
expire?: number;
msg?: string;
}
interface FeishuMessageSendResponse {
code?: number;
msg?: string;
}
interface FeishuEventEnvelope {
type?: string;
challenge?: string;
header?: {
event_type?: string;
token?: string;
};
event?: {
sender?: {
sender_id?: {
open_id?: string;
user_id?: string;
};
sender_type?: string;
};
message?: {
message_id?: string;
chat_id?: string;
chat_type?: string;
message_type?: string;
content?: string;
};
};
}
const MAX_MESSAGE_LENGTH = 3500;
export class FeishuAdapter implements ChannelAdapter {
readonly name = 'feishu';
private _status: ChannelStatus = 'disconnected';
private messageHandler?: (msg: InboundMessage) => void;
private tokenCache: { token: string; expiresAt: number } | null = null;
constructor(private readonly config: FeishuAdapterConfig) {}
get status(): ChannelStatus {
return this._status;
}
onMessage(handler: (msg: InboundMessage) => void): void {
this.messageHandler = handler;
}
async connect(): Promise<void> {
this._status = 'connected';
}
async disconnect(): Promise<void> {
this._status = 'disconnected';
}
async send(peerId: string, message: OutboundMessage): Promise<void> {
if (this._status !== 'connected') {
throw new Error('Feishu adapter not connected');
}
const text = message.text.trim();
if (!text) {
return;
}
const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text];
for (const chunk of chunks) {
await this.sendMessage(peerId, chunk);
}
}
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
let body = '';
try {
body = await readRequestBody(req, { maxBytes: 1_048_576 });
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request body' }));
return;
}
let payload: FeishuEventEnvelope;
try {
payload = JSON.parse(body) as FeishuEventEnvelope;
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
// URL verification flow
if (payload.type === 'url_verification' && payload.challenge) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ challenge: payload.challenge }));
return;
}
if (this.config.webhookToken && payload.header?.token !== this.config.webhookToken) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid webhook token' }));
return;
}
await this.handleEvent(payload);
res.writeHead(202, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ accepted: true }));
}
async handleEvent(payload: FeishuEventEnvelope): Promise<void> {
if (!this.messageHandler) {
return;
}
if (payload.header?.event_type !== 'im.message.receive_v1') {
return;
}
const message = payload.event?.message;
if (!message || message.message_type !== 'text') {
return;
}
const chatId = message.chat_id?.trim();
if (!chatId) {
return;
}
if (this.config.allowedChatIds && this.config.allowedChatIds.length > 0) {
if (!this.config.allowedChatIds.includes(chatId)) {
return;
}
}
const senderId = payload.event?.sender?.sender_id?.open_id?.trim()
|| payload.event?.sender?.sender_id?.user_id?.trim();
if (!senderId) {
return;
}
const text = parseFeishuText(message.content);
if (!text) {
return;
}
const mentionName = this.config.mentionName ?? 'flynn';
const mentionRegex = new RegExp(`(?:^|\\s)@?${escapeRegex(mentionName)}(?:\\b|:)`, 'i');
const isDm = (message.chat_type ?? '').toLowerCase() === 'p2p';
const mentionsBot = mentionRegex.test(text);
if (shouldIgnoreForMissingMention({
requireMention: this.config.requireMention,
defaultRequireMention: true,
mentionsBot: isDm || mentionsBot,
})) {
return;
}
const cleaned = text.replace(new RegExp(`^\\s*@?${escapeRegex(mentionName)}(?:\\b|:)\\s*`, 'i'), '').trim();
if (!cleaned) {
return;
}
this.messageHandler({
id: message.message_id ?? `feishu-${Date.now()}`,
channel: 'feishu',
senderId,
text: cleaned,
timestamp: Date.now(),
metadata: {
chatId,
chatType: message.chat_type,
replyPeerId: chatId,
},
});
}
private async sendMessage(chatId: string, text: string): Promise<void> {
const token = await this.getTenantAccessToken();
const endpoint = `${this.baseEndpoint()}/open-apis/im/v1/messages?receive_id_type=chat_id`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
receive_id: chatId,
msg_type: 'text',
content: JSON.stringify({ text }),
}),
});
const payload = await response.json().catch(() => ({})) as FeishuMessageSendResponse;
if (!response.ok || payload.code !== 0) {
throw new Error(`Feishu send failed (${response.status}): ${payload.msg ?? 'unknown error'}`);
}
}
private async getTenantAccessToken(): Promise<string> {
const now = Date.now();
if (this.tokenCache && this.tokenCache.expiresAt > now + 30_000) {
return this.tokenCache.token;
}
const endpoint = `${this.baseEndpoint()}/open-apis/auth/v3/tenant_access_token/internal`;
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({
app_id: this.config.appId,
app_secret: this.config.appSecret,
}),
});
const payload = await response.json().catch(() => ({})) as FeishuTenantTokenResponse;
if (!response.ok || payload.code !== 0 || !payload.tenant_access_token) {
throw new Error(`Feishu auth failed (${response.status}): ${payload.msg ?? 'missing token'}`);
}
const expireSecs = typeof payload.expire === 'number' ? payload.expire : 3600;
this.tokenCache = {
token: payload.tenant_access_token,
expiresAt: now + expireSecs * 1000,
};
return payload.tenant_access_token;
}
private baseEndpoint(): string {
return (this.config.endpoint ?? 'https://open.feishu.cn').replace(/\/+$/, '');
}
}
function parseFeishuText(content: string | undefined): string {
if (!content) {
return '';
}
try {
const parsed = JSON.parse(content) as { text?: string };
return (parsed.text ?? '').trim();
} catch {
return '';
}
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+1
View File
@@ -0,0 +1 @@
export { FeishuAdapter, type FeishuAdapterConfig } from './adapter.js';
+1
View File
@@ -22,4 +22,5 @@ export { TeamsAdapter, type TeamsAdapterConfig } from './teams/index.js';
export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './googleChat/index.js';
export { BlueBubblesAdapter, type BlueBubblesAdapterConfig } from './bluebubbles/index.js';
export { LineAdapter, type LineAdapterConfig } from './line/index.js';
export { FeishuAdapter, type FeishuAdapterConfig } from './feishu/index.js';
export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js';
+27
View File
@@ -608,6 +608,33 @@ describe('configSchema — line', () => {
});
});
describe('configSchema — feishu', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
};
it('accepts feishu config and defaults optional fields', () => {
const result = configSchema.parse({
...minimalConfig,
feishu: {
app_id: 'cli_a1b2c3',
app_secret: 'secret',
},
});
expect(result.feishu).toBeDefined();
if (!result.feishu) {
throw new Error('Expected feishu config');
}
expect(result.feishu.app_id).toBe('cli_a1b2c3');
expect(result.feishu.app_secret).toBe('secret');
expect(result.feishu.allowed_chat_ids).toEqual([]);
expect(result.feishu.require_mention).toBe(true);
expect(result.feishu.mention_name).toBe('flynn');
});
});
describe('configSchema — whatsapp', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
+11
View File
@@ -511,6 +511,16 @@ const lineSchema = z.object({
mention_name: z.string().default('flynn'),
}).optional();
const feishuSchema = z.object({
app_id: z.string().min(1, 'Feishu app_id is required'),
app_secret: z.string().min(1, 'Feishu app_secret is required'),
webhook_token: z.string().optional(),
allowed_chat_ids: z.array(z.string()).default([]),
require_mention: z.boolean().default(true),
mention_name: z.string().default('flynn'),
endpoint: z.string().url('Feishu endpoint must be a valid URL').optional(),
}).optional();
const browserSchema = z.object({
enabled: z.boolean().default(false),
executable_path: z.string().optional(),
@@ -691,6 +701,7 @@ export const configSchema = z.object({
google_chat: googleChatSchema,
bluebubbles: bluebubblesSchema,
line: lineSchema,
feishu: feishuSchema,
server: serverSchema.default({}),
models: modelsSchema,
backends: backendsSchema.default({}),
+36
View File
@@ -24,6 +24,7 @@ describe('registerChannels', () => {
setGoogleChatHandler: vi.fn(),
setBlueBubblesHandler: vi.fn(),
setLineHandler: vi.fn(),
setFeishuHandler: vi.fn(),
};
registerChannels({
@@ -57,6 +58,7 @@ describe('registerChannels', () => {
setGoogleChatHandler: vi.fn(),
setBlueBubblesHandler: vi.fn(),
setLineHandler: vi.fn(),
setFeishuHandler: vi.fn(),
};
registerChannels({
@@ -70,4 +72,38 @@ describe('registerChannels', () => {
expect(names).toContain('line');
expect(gateway.setLineHandler).toHaveBeenCalledTimes(1);
});
it('registers Feishu adapter when configured', () => {
const config = configSchema.parse({
telegram: { bot_token: 'test-token', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
feishu: {
app_id: 'cli_a1b2c3',
app_secret: 'secret',
allowed_chat_ids: ['oc_123'],
},
});
const channelRegistry = new ChannelRegistry();
const gateway = {
setWebhookHandler: vi.fn(),
setGmailHandler: vi.fn(),
setTeamsHandler: vi.fn(),
setGoogleChatHandler: vi.fn(),
setBlueBubblesHandler: vi.fn(),
setLineHandler: vi.fn(),
setFeishuHandler: vi.fn(),
};
registerChannels({
config,
channelRegistry,
hookEngine: new HookEngine(config.hooks),
gateway: gateway as unknown as Parameters<typeof registerChannels>[0]['gateway'],
});
const names = channelRegistry.list().map((adapter) => adapter.name);
expect(names).toContain('feishu');
expect(gateway.setFeishuHandler).toHaveBeenCalledTimes(1);
});
});
+16 -1
View File
@@ -1,6 +1,6 @@
import type { Config } from '../config/index.js';
import type { HookEngine } from '../hooks/index.js';
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, MattermostAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, LineAdapter, PairingManager } from '../channels/index.js';
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, MattermostAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, LineAdapter, FeishuAdapter, PairingManager } from '../channels/index.js';
import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js';
import type { GatewayServer } from '../gateway/index.js';
@@ -167,6 +167,21 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult {
gateway.setLineHandler(lineAdapter);
}
// Register Feishu adapter (if configured)
if (config.feishu) {
const feishuAdapter = new FeishuAdapter({
appId: config.feishu.app_id,
appSecret: config.feishu.app_secret,
webhookToken: config.feishu.webhook_token,
allowedChatIds: config.feishu.allowed_chat_ids.length > 0 ? config.feishu.allowed_chat_ids : undefined,
requireMention: config.feishu.require_mention,
mentionName: config.feishu.mention_name,
endpoint: config.feishu.endpoint,
});
channelRegistry.register(feishuAdapter);
gateway.setFeishuHandler(feishuAdapter);
}
// Register WebChat adapter (wraps the gateway)
const webChatAdapter = new WebChatAdapter({ gateway });
channelRegistry.register(webChatAdapter);
+2
View File
@@ -36,6 +36,7 @@ function makeBaseConfig(): Config {
google_chat: undefined,
bluebubbles: undefined,
line: undefined,
feishu: undefined,
} as unknown as Config;
}
@@ -55,6 +56,7 @@ describe('discoverServices', () => {
expect.objectContaining({ name: 'google_chat', status: 'not_configured' }),
expect.objectContaining({ name: 'bluebubbles', status: 'not_configured' }),
expect.objectContaining({ name: 'line', status: 'not_configured' }),
expect.objectContaining({ name: 'feishu', status: 'not_configured' }),
expect.objectContaining({ name: 'cron', status: 'not_configured' }),
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
expect.objectContaining({ name: 'web_search', status: 'configured' }),
+1
View File
@@ -59,6 +59,7 @@ export function discoverServices(
{ key: 'google_chat', name: 'google_chat', description: 'Google Chat bot' },
{ key: 'bluebubbles', name: 'bluebubbles', description: 'iMessage via BlueBubbles' },
{ key: 'line', name: 'line', description: 'LINE Messaging API bot' },
{ key: 'feishu', name: 'feishu', description: 'Feishu/Lark bot' },
];
for (const { key, name, description } of channelConfigs) {
+14
View File
@@ -52,6 +52,7 @@ import type { TeamsAdapter } from '../channels/teams/adapter.js';
import type { GoogleChatAdapter } from '../channels/googleChat/adapter.js';
import type { BlueBubblesAdapter } from '../channels/bluebubbles/adapter.js';
import type { LineAdapter } from '../channels/line/adapter.js';
import type { FeishuAdapter } from '../channels/feishu/adapter.js';
export interface GatewayServerConfig {
port: number;
@@ -123,6 +124,8 @@ export interface GatewayServerConfig {
blueBubblesHandler?: Pick<BlueBubblesAdapter, 'handleRequest'>;
/** Optional LINE adapter for inbound webhook events. */
lineHandler?: Pick<LineAdapter, 'handleRequest'>;
/** Optional Feishu adapter for inbound webhook events. */
feishuHandler?: Pick<FeishuAdapter, 'handleRequest'>;
}
export class GatewayServer {
@@ -733,6 +736,12 @@ export class GatewayServer {
return;
}
// Feishu events route — bypass gateway auth (Feishu webhook posts directly)
if (this.config.feishuHandler && req.method === 'POST' && req.url?.startsWith('/feishu/events')) {
await this.config.feishuHandler.handleRequest(req, res);
return;
}
// Apply auth to HTTP requests when configured
const authConfig = this.config.auth ?? {};
if (this.config.authHttp !== false && authConfig.token) {
@@ -856,6 +865,11 @@ export class GatewayServer {
this.config.lineHandler = handler;
}
/** Set the Feishu handler for inbound webhook HTTP routes (late binding). */
setFeishuHandler(handler: Pick<FeishuAdapter, 'handleRequest'>): void {
this.config.feishuHandler = handler;
}
private async startDiscovery(host: string, port: number): Promise<void> {
const discovery = this.config.discovery;
if (!discovery?.enabled) {