Add Feishu channel adapter with webhook and send path
This commit is contained in:
@@ -6,7 +6,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces.
|
|||||||
|
|
||||||
- **Multi-Frontend**: Telegram bot + Terminal UI (minimal & fullscreen modes) + Web UI dashboard
|
- **Multi-Frontend**: Telegram bot + Terminal UI (minimal & fullscreen modes) + Web UI dashboard
|
||||||
- **Multi-Model**: Anthropic Claude, OpenAI, GitHub Copilot, Gemini, Bedrock, Zhipu AI (GLM), xAI (Grok), Ollama, llama.cpp with intelligent routing
|
- **Multi-Model**: Anthropic Claude, OpenAI, GitHub Copilot, Gemini, Bedrock, Zhipu AI (GLM), xAI (Grok), Ollama, llama.cpp with intelligent routing
|
||||||
- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Mattermost, Microsoft Teams, Google Chat, LINE, and iMessage (BlueBubbles) with unified adapter interface
|
- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Mattermost, Microsoft Teams, Google Chat, LINE, Feishu/Lark, and iMessage (BlueBubbles) with unified adapter interface
|
||||||
- **Web Dashboard**: SPA control panel with health monitoring, chat, session browser, usage stats, and settings editor
|
- **Web Dashboard**: SPA control panel with health monitoring, chat, session browser, usage stats, and settings editor
|
||||||
- **Model Switching**: Switch between cloud/local models on demand
|
- **Model Switching**: Switch between cloud/local models on demand
|
||||||
- **Session Persistence**: SQLite-backed conversation history
|
- **Session Persistence**: SQLite-backed conversation history
|
||||||
@@ -206,6 +206,19 @@ line:
|
|||||||
# LINE webhook endpoint should point to:
|
# LINE webhook endpoint should point to:
|
||||||
# POST https://<your-flynn-host>/line/events
|
# POST https://<your-flynn-host>/line/events
|
||||||
|
|
||||||
|
# Optional: Feishu / Lark
|
||||||
|
feishu:
|
||||||
|
app_id: "${FEISHU_APP_ID}"
|
||||||
|
app_secret: "${FEISHU_APP_SECRET}"
|
||||||
|
webhook_token: "${FEISHU_WEBHOOK_TOKEN}"
|
||||||
|
allowed_chat_ids: []
|
||||||
|
require_mention: true
|
||||||
|
mention_name: "flynn"
|
||||||
|
endpoint: "https://open.feishu.cn"
|
||||||
|
|
||||||
|
# Feishu webhook endpoint should point to:
|
||||||
|
# POST https://<your-flynn-host>/feishu/events
|
||||||
|
|
||||||
models:
|
models:
|
||||||
default:
|
default:
|
||||||
provider: anthropic
|
provider: anthropic
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ telegram:
|
|||||||
# require_mention: true
|
# require_mention: true
|
||||||
# mention_name: flynn
|
# mention_name: flynn
|
||||||
|
|
||||||
|
# Optional: Feishu / Lark
|
||||||
|
# feishu:
|
||||||
|
# app_id: ${FEISHU_APP_ID}
|
||||||
|
# app_secret: ${FEISHU_APP_SECRET}
|
||||||
|
# webhook_token: ${FEISHU_WEBHOOK_TOKEN}
|
||||||
|
# allowed_chat_ids: [] # Empty = allow all chats
|
||||||
|
# require_mention: true
|
||||||
|
# mention_name: flynn
|
||||||
|
# endpoint: https://open.feishu.cn
|
||||||
|
|
||||||
server:
|
server:
|
||||||
# Tailscale Serve config (optional). Enable `serve: true` to expose the
|
# Tailscale Serve config (optional). Enable `serve: true` to expose the
|
||||||
# gateway to your tailnet via `tailscale serve`.
|
# gateway to your tailnet via `tailscale serve`.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ src/
|
|||||||
hooks/ Confirm/log/silent policy + autonomy resolution
|
hooks/ Confirm/log/silent policy + autonomy resolution
|
||||||
sandbox/ Docker sandbox manager + sandboxed tool wrappers
|
sandbox/ Docker sandbox manager + sandboxed tool wrappers
|
||||||
models/ Provider clients + model router + retry/cost/capabilities
|
models/ Provider clients + model router + retry/cost/capabilities
|
||||||
channels/ Chat adapters + pairing gate (Telegram/Discord/Slack/WhatsApp/Matrix/Signal/Mattermost/etc.)
|
channels/ Chat adapters + pairing gate (Telegram/Discord/Slack/WhatsApp/Matrix/Signal/Mattermost/LINE/Feishu/etc.)
|
||||||
gateway/ WebSocket JSON-RPC server + web UI + handlers
|
gateway/ WebSocket JSON-RPC server + web UI + handlers
|
||||||
memory/ Hybrid search + embeddings + persistence
|
memory/ Hybrid search + embeddings + persistence
|
||||||
session/ SQLite store + session mgmt
|
session/ SQLite store + session mgmt
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Feishu Channel Adapter Checklist
|
||||||
|
|
||||||
|
**Date:** 2026-02-16
|
||||||
|
**Scope:** Implement Feishu/Lark adapter as the second item in the LINE/Feishu/Zalo channel gap set.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a Feishu channel adapter with webhook ingress, outbound send path, mention/allowlist gating, and runtime wiring through daemon + gateway.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- Added Feishu adapter:
|
||||||
|
- `src/channels/feishu/adapter.ts`
|
||||||
|
- `src/channels/feishu/index.ts`
|
||||||
|
- Inbound webhook handling:
|
||||||
|
- endpoint: `POST /feishu/events`
|
||||||
|
- supports Feishu URL verification challenge flow.
|
||||||
|
- validates `header.token` when `webhook_token` is configured.
|
||||||
|
- Message normalization:
|
||||||
|
- handles `im.message.receive_v1` text messages.
|
||||||
|
- parses `message.content` JSON `{ "text": "..." }`.
|
||||||
|
- emits normalized `InboundMessage` with `metadata.replyPeerId`.
|
||||||
|
- Outbound messaging:
|
||||||
|
- obtains tenant access token via internal app credentials.
|
||||||
|
- sends text messages via Feishu IM v1 messages API.
|
||||||
|
- Gating:
|
||||||
|
- optional chat allowlist (`allowed_chat_ids`).
|
||||||
|
- mention policy (`require_mention`, `mention_name`) with DM bypass.
|
||||||
|
- Runtime integration:
|
||||||
|
- config schema + defaults for `feishu`.
|
||||||
|
- daemon registration and gateway handler binding.
|
||||||
|
- gateway route wiring + services discovery visibility.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `src/channels/feishu/adapter.test.ts`
|
||||||
|
- name/status
|
||||||
|
- outbound token + send path
|
||||||
|
- url verification handling
|
||||||
|
- inbound event normalization
|
||||||
|
- webhook token enforcement
|
||||||
|
- `src/daemon/channels.test.ts`
|
||||||
|
- adapter registration + gateway `setFeishuHandler` binding
|
||||||
|
- `src/config/schema.test.ts`
|
||||||
|
- feishu config parsing/defaults
|
||||||
|
- `src/gateway/handlers/services.test.ts`
|
||||||
|
- `feishu` service presence
|
||||||
|
|
||||||
|
## Validation Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:run src/channels/feishu/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
+30
-3
@@ -620,6 +620,33 @@
|
|||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/channels/line/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck + pnpm build passing"
|
"test_status": "pnpm test:run src/channels/line/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck + pnpm build passing"
|
||||||
},
|
},
|
||||||
|
"feishu-channel-adapter": {
|
||||||
|
"file": "2026-02-16-feishu-channel-adapter-checklist.md",
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-16",
|
||||||
|
"updated": "2026-02-16",
|
||||||
|
"summary": "Implemented Feishu/Lark channel adapter with webhook ingress (`/feishu/events`), URL verification challenge handling, webhook token validation, mention/allowlist gating, outbound IM send path via tenant access token, daemon/gateway wiring, and services visibility.",
|
||||||
|
"files_created": [
|
||||||
|
"docs/plans/2026-02-16-feishu-channel-adapter-checklist.md",
|
||||||
|
"src/channels/feishu/adapter.ts",
|
||||||
|
"src/channels/feishu/adapter.test.ts",
|
||||||
|
"src/channels/feishu/index.ts"
|
||||||
|
],
|
||||||
|
"files_modified": [
|
||||||
|
"src/channels/index.ts",
|
||||||
|
"src/daemon/channels.ts",
|
||||||
|
"src/daemon/channels.test.ts",
|
||||||
|
"src/config/schema.ts",
|
||||||
|
"src/config/schema.test.ts",
|
||||||
|
"src/gateway/server.ts",
|
||||||
|
"src/gateway/handlers/services.ts",
|
||||||
|
"src/gateway/handlers/services.test.ts",
|
||||||
|
"README.md",
|
||||||
|
"config/default.yaml",
|
||||||
|
"docs/architecture/CONTRIBUTOR_MAP.md"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm test:run src/channels/feishu/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck + pnpm build passing"
|
||||||
|
},
|
||||||
"qmd-backend": {
|
"qmd-backend": {
|
||||||
"file": "2026-02-16-qmd-backend-checklist.md",
|
"file": "2026-02-16-qmd-backend-checklist.md",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -3181,7 +3208,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1802,
|
"total_test_count": 1808,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
@@ -3196,12 +3223,12 @@
|
|||||||
"tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
|
"tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
|
||||||
"tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings",
|
"tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings",
|
||||||
"tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes",
|
"tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes",
|
||||||
"feature_gap_scorecard": "124/128 match (97%), 0 partial (0%), 4 missing (3%)",
|
"feature_gap_scorecard": "125/128 match (98%), 0 partial (0%), 3 missing (2%)",
|
||||||
"operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done",
|
"operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done",
|
||||||
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
||||||
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
||||||
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
||||||
"next_up": "OpenClaw gap: Feishu channel adapter (open next scoped implementation checklist)"
|
"next_up": "OpenClaw gap: Zalo channel adapter (open next scoped implementation checklist)"
|
||||||
},
|
},
|
||||||
"soul_md_and_cron_create": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -22,4 +22,5 @@ export { TeamsAdapter, type TeamsAdapterConfig } from './teams/index.js';
|
|||||||
export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './googleChat/index.js';
|
export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './googleChat/index.js';
|
||||||
export { BlueBubblesAdapter, type BlueBubblesAdapterConfig } from './bluebubbles/index.js';
|
export { BlueBubblesAdapter, type BlueBubblesAdapterConfig } from './bluebubbles/index.js';
|
||||||
export { LineAdapter, type LineAdapterConfig } from './line/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';
|
export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js';
|
||||||
|
|||||||
@@ -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', () => {
|
describe('configSchema — whatsapp', () => {
|
||||||
const minimalConfig = {
|
const minimalConfig = {
|
||||||
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
|
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
|
||||||
|
|||||||
@@ -511,6 +511,16 @@ const lineSchema = z.object({
|
|||||||
mention_name: z.string().default('flynn'),
|
mention_name: z.string().default('flynn'),
|
||||||
}).optional();
|
}).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({
|
const browserSchema = z.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
executable_path: z.string().optional(),
|
executable_path: z.string().optional(),
|
||||||
@@ -691,6 +701,7 @@ export const configSchema = z.object({
|
|||||||
google_chat: googleChatSchema,
|
google_chat: googleChatSchema,
|
||||||
bluebubbles: bluebubblesSchema,
|
bluebubbles: bluebubblesSchema,
|
||||||
line: lineSchema,
|
line: lineSchema,
|
||||||
|
feishu: feishuSchema,
|
||||||
server: serverSchema.default({}),
|
server: serverSchema.default({}),
|
||||||
models: modelsSchema,
|
models: modelsSchema,
|
||||||
backends: backendsSchema.default({}),
|
backends: backendsSchema.default({}),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe('registerChannels', () => {
|
|||||||
setGoogleChatHandler: vi.fn(),
|
setGoogleChatHandler: vi.fn(),
|
||||||
setBlueBubblesHandler: vi.fn(),
|
setBlueBubblesHandler: vi.fn(),
|
||||||
setLineHandler: vi.fn(),
|
setLineHandler: vi.fn(),
|
||||||
|
setFeishuHandler: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
registerChannels({
|
registerChannels({
|
||||||
@@ -57,6 +58,7 @@ describe('registerChannels', () => {
|
|||||||
setGoogleChatHandler: vi.fn(),
|
setGoogleChatHandler: vi.fn(),
|
||||||
setBlueBubblesHandler: vi.fn(),
|
setBlueBubblesHandler: vi.fn(),
|
||||||
setLineHandler: vi.fn(),
|
setLineHandler: vi.fn(),
|
||||||
|
setFeishuHandler: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
registerChannels({
|
registerChannels({
|
||||||
@@ -70,4 +72,38 @@ describe('registerChannels', () => {
|
|||||||
expect(names).toContain('line');
|
expect(names).toContain('line');
|
||||||
expect(gateway.setLineHandler).toHaveBeenCalledTimes(1);
|
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
@@ -1,6 +1,6 @@
|
|||||||
import type { Config } from '../config/index.js';
|
import type { Config } from '../config/index.js';
|
||||||
import type { HookEngine } from '../hooks/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 { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js';
|
||||||
import type { GatewayServer } from '../gateway/index.js';
|
import type { GatewayServer } from '../gateway/index.js';
|
||||||
|
|
||||||
@@ -167,6 +167,21 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult {
|
|||||||
gateway.setLineHandler(lineAdapter);
|
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)
|
// Register WebChat adapter (wraps the gateway)
|
||||||
const webChatAdapter = new WebChatAdapter({ gateway });
|
const webChatAdapter = new WebChatAdapter({ gateway });
|
||||||
channelRegistry.register(webChatAdapter);
|
channelRegistry.register(webChatAdapter);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function makeBaseConfig(): Config {
|
|||||||
google_chat: undefined,
|
google_chat: undefined,
|
||||||
bluebubbles: undefined,
|
bluebubbles: undefined,
|
||||||
line: undefined,
|
line: undefined,
|
||||||
|
feishu: undefined,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ describe('discoverServices', () => {
|
|||||||
expect.objectContaining({ name: 'google_chat', status: 'not_configured' }),
|
expect.objectContaining({ name: 'google_chat', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'bluebubbles', status: 'not_configured' }),
|
expect.objectContaining({ name: 'bluebubbles', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'line', 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: 'cron', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
|
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'web_search', status: 'configured' }),
|
expect.objectContaining({ name: 'web_search', status: 'configured' }),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function discoverServices(
|
|||||||
{ key: 'google_chat', name: 'google_chat', description: 'Google Chat bot' },
|
{ key: 'google_chat', name: 'google_chat', description: 'Google Chat bot' },
|
||||||
{ key: 'bluebubbles', name: 'bluebubbles', description: 'iMessage via BlueBubbles' },
|
{ key: 'bluebubbles', name: 'bluebubbles', description: 'iMessage via BlueBubbles' },
|
||||||
{ key: 'line', name: 'line', description: 'LINE Messaging API bot' },
|
{ key: 'line', name: 'line', description: 'LINE Messaging API bot' },
|
||||||
|
{ key: 'feishu', name: 'feishu', description: 'Feishu/Lark bot' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { key, name, description } of channelConfigs) {
|
for (const { key, name, description } of channelConfigs) {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import type { TeamsAdapter } from '../channels/teams/adapter.js';
|
|||||||
import type { GoogleChatAdapter } from '../channels/googleChat/adapter.js';
|
import type { GoogleChatAdapter } from '../channels/googleChat/adapter.js';
|
||||||
import type { BlueBubblesAdapter } from '../channels/bluebubbles/adapter.js';
|
import type { BlueBubblesAdapter } from '../channels/bluebubbles/adapter.js';
|
||||||
import type { LineAdapter } from '../channels/line/adapter.js';
|
import type { LineAdapter } from '../channels/line/adapter.js';
|
||||||
|
import type { FeishuAdapter } from '../channels/feishu/adapter.js';
|
||||||
|
|
||||||
export interface GatewayServerConfig {
|
export interface GatewayServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -123,6 +124,8 @@ export interface GatewayServerConfig {
|
|||||||
blueBubblesHandler?: Pick<BlueBubblesAdapter, 'handleRequest'>;
|
blueBubblesHandler?: Pick<BlueBubblesAdapter, 'handleRequest'>;
|
||||||
/** Optional LINE adapter for inbound webhook events. */
|
/** Optional LINE adapter for inbound webhook events. */
|
||||||
lineHandler?: Pick<LineAdapter, 'handleRequest'>;
|
lineHandler?: Pick<LineAdapter, 'handleRequest'>;
|
||||||
|
/** Optional Feishu adapter for inbound webhook events. */
|
||||||
|
feishuHandler?: Pick<FeishuAdapter, 'handleRequest'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatewayServer {
|
export class GatewayServer {
|
||||||
@@ -733,6 +736,12 @@ export class GatewayServer {
|
|||||||
return;
|
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
|
// Apply auth to HTTP requests when configured
|
||||||
const authConfig = this.config.auth ?? {};
|
const authConfig = this.config.auth ?? {};
|
||||||
if (this.config.authHttp !== false && authConfig.token) {
|
if (this.config.authHttp !== false && authConfig.token) {
|
||||||
@@ -856,6 +865,11 @@ export class GatewayServer {
|
|||||||
this.config.lineHandler = handler;
|
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> {
|
private async startDiscovery(host: string, port: number): Promise<void> {
|
||||||
const discovery = this.config.discovery;
|
const discovery = this.config.discovery;
|
||||||
if (!discovery?.enabled) {
|
if (!discovery?.enabled) {
|
||||||
|
|||||||
Reference in New Issue
Block a user