Add Feishu channel adapter with webhook and send path
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FeishuAdapter, type FeishuAdapterConfig } from './adapter.js';
|
||||
Reference in New Issue
Block a user