chore: restore feishu worktree snapshot
This commit is contained in:
1 parent
e6d707fe21
commit
16d2f1af5d
14 files changed
+1314
-990
No files matched your search
@@ -8,6 +8,16 @@
|
||||
"chrome-devtools-mcp@latest"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"feishu": {
|
||||
"command": "node",
|
||||
"args": ["packages/feishu-mcp/dist/index.js"],
|
||||
"env": {
|
||||
"FEISHU_APP_ID": "${bridge_feishu_app_id}",
|
||||
"FEISHU_APP_SECRET": "${bridge_feishu_app_secret}",
|
||||
"FEISHU_DOMAIN": "${bridge_feishu_domain}",
|
||||
"FEISHU_USER_ACCESS_TOKEN": "${bridge_feishu_user_token}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -107,7 +107,32 @@ Schema 定义在 `src/lib/db.ts`,12 张表:
|
||||
- `bridge-manager.ts` — 生命周期编排
|
||||
- `markdown/` — Markdown → IR → 渠道特定格式(Telegram HTML / 飞书卡片)
|
||||
- `adapters/telegram-adapter.ts` — Telegram 长轮询
|
||||
- `adapters/feishu-adapter.ts` — 飞书 WebSocket + REST
|
||||
- `adapters/feishu-adapter.ts` — 飞书薄代理(委托给 Channel Plugin)
|
||||
|
||||
### Channel Plugin 层
|
||||
|
||||
`src/lib/channels/` — 结构化渠道插件,提供 `ChannelPlugin<T>` 合约。
|
||||
|
||||
- `types.ts` — `ChannelPlugin`/`ChannelCapabilities`/`ProbeResult`/`CardStreamController` 接口
|
||||
- `channel-plugin-adapter.ts` — Plugin → `BaseChannelAdapter` 桥接适配器
|
||||
- `feishu/` — 飞书渠道插件(模块化拆分)
|
||||
- `types.ts` — 飞书内部类型常量
|
||||
- `config.ts` — `FeishuConfig` 结构化配置 + 校验
|
||||
- `gateway.ts` — WSClient 生命周期 + 连接状态机 + probe
|
||||
- `inbound.ts` — 入站消息处理 + 资源下载
|
||||
- `outbound.ts` — 出站消息渲染(card/post/permission)
|
||||
- `identity.ts` — Bot 身份解析 + @mention 检测
|
||||
- `policy.ts` — 访问控制 + 群策略
|
||||
- `card-controller.ts` — CardStreamController 接口 + 占位
|
||||
- `index.ts` — `FeishuChannelPlugin` 入口
|
||||
|
||||
### Remote Core 层
|
||||
|
||||
`src/lib/remote/` — 远程 Host/Controller/Session/Lease 合约(接口 + 骨架)。
|
||||
|
||||
- `types.ts` — `RemoteHost`/`RemoteController`/`SessionLease`/`RemoteEvent` 接口
|
||||
- `remote-manager.ts` — 轻量运行时骨架
|
||||
- `index.ts` — 公开导出
|
||||
|
||||
详细文档:`docs/handover/bridge-system.md`
|
||||
|
||||
|
||||
@@ -281,3 +281,41 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案):
|
||||
- Android App、桌面 Controller 和 IM 渠道都将共享同一套 Remote Core
|
||||
- 飞书不再只是一个 adapter,而会逐步拆分成独立的渠道模块族
|
||||
- 当前 Bridge 仍是实现基础,但不再是远程能力的最终抽象边界
|
||||
|
||||
### V2 实施状态(codex/feishu-remote-v2)
|
||||
|
||||
**已完成:**
|
||||
|
||||
1. **Channel Plugin 合约** (`src/lib/channels/types.ts`)
|
||||
- `ChannelPlugin<T>` 接口:config/capabilities/lifecycle/inbound/outbound/policy
|
||||
- `ChannelCapabilities`:streaming、inlineButtons、reactions 等能力声明
|
||||
- `ProbeResult`:健康检查结果
|
||||
- `CardStreamController`:流式卡片接口(占位)
|
||||
|
||||
2. **ChannelPluginAdapter** (`src/lib/channels/channel-plugin-adapter.ts`)
|
||||
- 将 `ChannelPlugin<T>` 桥接为 `BaseChannelAdapter`
|
||||
- bridge-manager 无需修改即可使用新插件
|
||||
|
||||
3. **飞书模块拆分** (`src/lib/channels/feishu/`)
|
||||
- `types.ts` — 内部类型 + 常量
|
||||
- `config.ts` — `FeishuConfig` 结构化配置 + 校验 + `configFromSettings()`
|
||||
- `gateway.ts` — WSClient 生命周期 + 连接状态机 + 指标 + probe
|
||||
- `inbound.ts` — 入站消息处理 + 内容解析 + 资源下载
|
||||
- `outbound.ts` — 出站消息渲染(card/post/text/permission)
|
||||
- `identity.ts` — Bot 身份解析 + @mention 检测
|
||||
- `policy.ts` — 用户授权 + 群聊策略
|
||||
- `card-controller.ts` — CardStreamController 占位
|
||||
- `index.ts` — `FeishuChannelPlugin` 组合入口
|
||||
|
||||
4. **结构化配置** (`channel_configs` DB 表 + API 路由)
|
||||
- `GET/PUT /api/channels/feishu/config` — 读写结构化配置
|
||||
- 向后兼容:读 channel_configs → 回退到 settings
|
||||
|
||||
5. **Status / Probe** (`/api/channels/feishu/status`)
|
||||
- 连接状态、Bot 身份、指标、可选 probe
|
||||
|
||||
6. **Remote Core 合约** (`src/lib/remote/`)
|
||||
- `RemoteHost`/`RemoteController`/`SessionLease`/`RemoteEvent` 接口
|
||||
- `RemoteManager` 轻量骨架
|
||||
|
||||
7. **原 feishu-adapter.ts** 改为薄代理(~15 行)
|
||||
@@ -516,7 +516,7 @@ OpenClaw 官方飞书插件最值得借的不是“功能数量”,而是能
|
||||
|
||||
如果继续推进,下一份文档最值得做的是:
|
||||
|
||||
- `远程方案 V2 执行计划`
|
||||
- `Remote Contract / Channel Contract 草案`
|
||||
- `Feishu V2 渠道模块拆分设计`
|
||||
- `Remote App 信息架构 + API 清单`
|
||||
- ~~`远程方案 V2 执行计划`~~ ✅ 已完成 → `docs/exec-plans/active/feishu-v2-channel-plugin.md`
|
||||
- ~~`Remote Contract / Channel Contract 草案`~~ ✅ 已完成 → `src/lib/channels/types.ts` + `src/lib/remote/types.ts`
|
||||
- ~~`Feishu V2 渠道模块拆分设计`~~ ✅ 已完成 → `src/lib/channels/feishu/`
|
||||
- `Remote App 信息架构 + API 清单` — 待后续实施
|
||||
Generated
+879
-1
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,11 @@ export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/bridge — Return bridge status (pure query, no side effects)
|
||||
* GET /api/bridge — Return bridge status (pure query, no side effects).
|
||||
*
|
||||
* Does NOT run probe — probe is an explicit action via the per-channel
|
||||
* status endpoint (e.g. /api/channels/feishu/status?probe=true).
|
||||
* This endpoint is polled every 5s by useBridgeStatus so it must stay cheap.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { MCPServerConfig } from '@/types';
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Read MCP server configs from ~/.claude.json and ~/.claude/settings.json */
|
||||
/** Read MCP server configs from ~/.claude.json, ~/.claude/settings.json, and project .mcp.json */
|
||||
function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
|
||||
try {
|
||||
const readJson = (p: string): Record<string, unknown> => {
|
||||
@@ -23,10 +23,25 @@ function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
|
||||
};
|
||||
const userConfig = readJson(path.join(os.homedir(), '.claude.json'));
|
||||
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'));
|
||||
// Also read project-level .mcp.json
|
||||
const projectMcp = readJson(path.join(process.cwd(), '.mcp.json'));
|
||||
const merged = {
|
||||
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
...((projectMcp.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
};
|
||||
// Resolve ${...} placeholders in env values against DB settings
|
||||
for (const server of Object.values(merged)) {
|
||||
if (server.env) {
|
||||
for (const [key, value] of Object.entries(server.env)) {
|
||||
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
|
||||
const settingKey = value.slice(2, -1);
|
||||
const resolved = getSetting(settingKey);
|
||||
server.env[key] = resolved || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
|
||||
@@ -555,6 +555,53 @@ const en = {
|
||||
'feishu.step4': 'Add event subscription: im.message.receive_v1',
|
||||
'feishu.step5': 'Publish the app version and approve it in the admin console',
|
||||
'feishu.step6': 'Paste the credentials above and click "Test Connection" to verify',
|
||||
'feishu.replyMode': 'Reply Mode',
|
||||
'feishu.replyModeInline': 'Inline — reply directly in chat',
|
||||
'feishu.replyModeThread': 'Thread — reply in message thread',
|
||||
'feishu.streaming': 'Streaming Cards',
|
||||
'feishu.streamingDesc': 'Stream responses via updateable cards (requires CardKit v2)',
|
||||
'feishu.blockStreaming': 'Block Streaming',
|
||||
'feishu.blockStreamingDesc': 'Stream responses at block level',
|
||||
'feishu.threadSession': 'Thread Sessions',
|
||||
'feishu.threadSessionDesc': 'Use message threads as separate conversation sessions',
|
||||
'feishu.connectionState': 'Connection State',
|
||||
'feishu.connectionStateConnected': 'Connected',
|
||||
'feishu.connectionStateDisconnected': 'Disconnected',
|
||||
'feishu.connectionStateConnecting': 'Connecting...',
|
||||
'feishu.connectionStateReconnecting': 'Reconnecting...',
|
||||
'feishu.metrics': 'Metrics',
|
||||
'feishu.metricsMessagesIn': 'Messages In',
|
||||
'feishu.metricsMessagesOut': 'Messages Out',
|
||||
'feishu.metricsErrors': 'Errors',
|
||||
'feishu.probe': 'Probe',
|
||||
'feishu.probeSuccess': 'Probe successful',
|
||||
'feishu.probeFailed': 'Probe failed',
|
||||
'feishu.capabilities': 'Platform Capabilities',
|
||||
'feishu.capabilities.doc': 'Documents',
|
||||
'feishu.capabilities.sheet': 'Spreadsheets',
|
||||
'feishu.capabilities.calendar': 'Calendar',
|
||||
'feishu.capabilities.task': 'Tasks',
|
||||
'feishu.capabilities.bitable': 'Bitable (Tables)',
|
||||
'feishu.diagnose': 'Diagnostics',
|
||||
'feishu.diagnose.title': 'Feishu Connection Diagnostics',
|
||||
'feishu.diagnose.config': 'Configuration',
|
||||
'feishu.diagnose.connection': 'Connection',
|
||||
'feishu.diagnose.permissions': 'Permissions',
|
||||
'feishu.diagnose.running': 'Running diagnostics...',
|
||||
|
||||
// ── Channels (generic) ────────────────────────────────────
|
||||
'channels.configSaved': 'Configuration saved',
|
||||
'channels.configError': 'Configuration error',
|
||||
|
||||
// ── Remote ────────────────────────────────────────────────
|
||||
'remote.title': 'Remote Control',
|
||||
'remote.host': 'Host',
|
||||
'remote.controllers': 'Controllers',
|
||||
'remote.sessions': 'Remote Sessions',
|
||||
'remote.lease': 'Session Lease',
|
||||
'remote.leaseAcquired': 'Lease acquired',
|
||||
'remote.leaseReleased': 'Lease released',
|
||||
'remote.leaseExpired': 'Lease expired',
|
||||
|
||||
// ── Settings: Remote Bridge ────────────────────────────────
|
||||
'settings.bridge': 'Remote Bridge',
|
||||
|
||||
@@ -552,6 +552,53 @@ const zh: Record<TranslationKey, string> = {
|
||||
'feishu.step4': '添加事件订阅:im.message.receive_v1',
|
||||
'feishu.step5': '发布应用版本并在管理后台审批通过',
|
||||
'feishu.step6': '将凭据粘贴到上方,点击「测试连接」验证',
|
||||
'feishu.replyMode': '回复模式',
|
||||
'feishu.replyModeInline': '内联 — 直接在聊天中回复',
|
||||
'feishu.replyModeThread': '话题 — 在消息话题中回复',
|
||||
'feishu.streaming': '流式卡片',
|
||||
'feishu.streamingDesc': '通过可更新卡片流式输出响应(需要 CardKit v2)',
|
||||
'feishu.blockStreaming': 'Block 流式',
|
||||
'feishu.blockStreamingDesc': '按 Block 级别流式输出响应',
|
||||
'feishu.threadSession': '话题会话',
|
||||
'feishu.threadSessionDesc': '将消息话题作为独立的对话会话',
|
||||
'feishu.connectionState': '连接状态',
|
||||
'feishu.connectionStateConnected': '已连接',
|
||||
'feishu.connectionStateDisconnected': '未连接',
|
||||
'feishu.connectionStateConnecting': '连接中...',
|
||||
'feishu.connectionStateReconnecting': '重连中...',
|
||||
'feishu.metrics': '指标',
|
||||
'feishu.metricsMessagesIn': '接收消息',
|
||||
'feishu.metricsMessagesOut': '发送消息',
|
||||
'feishu.metricsErrors': '错误',
|
||||
'feishu.probe': '探测',
|
||||
'feishu.probeSuccess': '探测成功',
|
||||
'feishu.probeFailed': '探测失败',
|
||||
'feishu.capabilities': '平台能力',
|
||||
'feishu.capabilities.doc': '云文档',
|
||||
'feishu.capabilities.sheet': '电子表格',
|
||||
'feishu.capabilities.calendar': '日历',
|
||||
'feishu.capabilities.task': '任务',
|
||||
'feishu.capabilities.bitable': '多维表格',
|
||||
'feishu.diagnose': '诊断',
|
||||
'feishu.diagnose.title': '飞书连接诊断',
|
||||
'feishu.diagnose.config': '配置',
|
||||
'feishu.diagnose.connection': '连接',
|
||||
'feishu.diagnose.permissions': '权限',
|
||||
'feishu.diagnose.running': '正在运行诊断...',
|
||||
|
||||
// ── 渠道(通用)────────────────────────────────────────────
|
||||
'channels.configSaved': '配置已保存',
|
||||
'channels.configError': '配置错误',
|
||||
|
||||
// ── 远程控制 ──────────────────────────────────────────────
|
||||
'remote.title': '远程控制',
|
||||
'remote.host': '主机',
|
||||
'remote.controllers': '控制器',
|
||||
'remote.sessions': '远程会话',
|
||||
'remote.lease': '会话租约',
|
||||
'remote.leaseAcquired': '租约已获取',
|
||||
'remote.leaseReleased': '租约已释放',
|
||||
'remote.leaseExpired': '租约已过期',
|
||||
|
||||
// ── Settings: Remote Bridge ────────────────────────────────
|
||||
'settings.bridge': '远程桥接',
|
||||
|
||||
@@ -1,951 +1,16 @@
|
||||
/**
|
||||
* Feishu (Lark) Adapter — implements BaseChannelAdapter for Feishu Bot API.
|
||||
* Feishu adapter — thin proxy that delegates to FeishuChannelPlugin.
|
||||
*
|
||||
* Uses the official @larksuiteoapi/node-sdk WSClient for real-time event
|
||||
* subscription and REST Client for message sending / resource downloading.
|
||||
* Routes messages through an internal async queue (same pattern as Telegram).
|
||||
*
|
||||
* Rendering strategy (aligned with Openclaw):
|
||||
* - Code blocks / tables → interactive card (schema 2.0 markdown)
|
||||
* - Other text → post (msg_type: 'post') with md tag
|
||||
* - Permission prompts → interactive card with action buttons
|
||||
*
|
||||
* card.action.trigger events are handled via EventDispatcher (Openclaw pattern):
|
||||
* button clicks are converted to synthetic text messages and routed through
|
||||
* the normal /perm command processing pipeline.
|
||||
* The actual implementation lives in src/lib/channels/feishu/.
|
||||
* This file exists only to maintain the existing self-registration pattern
|
||||
* used by bridge-manager via adapters/index.ts.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import * as lark from '@larksuiteoapi/node-sdk';
|
||||
import type {
|
||||
ChannelType,
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
SendResult,
|
||||
} from '../types';
|
||||
import type { FileAttachment } from '@/types';
|
||||
import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter';
|
||||
import { insertAuditLog } from '../../db';
|
||||
import { getSetting } from '../../db';
|
||||
import {
|
||||
htmlToFeishuMarkdown,
|
||||
preprocessFeishuMarkdown,
|
||||
hasComplexMarkdown,
|
||||
buildCardContent,
|
||||
buildPostContent,
|
||||
} from '../markdown/feishu';
|
||||
|
||||
/** Max number of message_ids to keep for dedup. */
|
||||
const DEDUP_MAX = 1000;
|
||||
|
||||
/** Max file download size (20 MB). */
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
/** Feishu emoji type for typing indicator (same as Openclaw). */
|
||||
const TYPING_EMOJI = 'Typing';
|
||||
|
||||
/** Shape of the SDK's im.message.receive_v1 event data. */
|
||||
type FeishuMessageEventData = {
|
||||
sender: {
|
||||
sender_id?: {
|
||||
open_id?: string;
|
||||
union_id?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
sender_type: string;
|
||||
tenant_key?: string;
|
||||
};
|
||||
message: {
|
||||
message_id: string;
|
||||
chat_id: string;
|
||||
chat_type: string;
|
||||
message_type: string;
|
||||
content: string;
|
||||
create_time: string;
|
||||
mentions?: Array<{
|
||||
key: string;
|
||||
id: { open_id?: string; union_id?: string; user_id?: string };
|
||||
name: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/** MIME type guesses by message_type. */
|
||||
const MIME_BY_TYPE: Record<string, string> = {
|
||||
image: 'image/png',
|
||||
file: 'application/octet-stream',
|
||||
audio: 'audio/ogg',
|
||||
video: 'video/mp4',
|
||||
media: 'application/octet-stream',
|
||||
};
|
||||
|
||||
export class FeishuAdapter extends BaseChannelAdapter {
|
||||
readonly channelType: ChannelType = 'feishu';
|
||||
|
||||
private running = false;
|
||||
private queue: InboundMessage[] = [];
|
||||
private waiters: Array<(msg: InboundMessage | null) => void> = [];
|
||||
private wsClient: lark.WSClient | null = null;
|
||||
private restClient: lark.Client | null = null;
|
||||
private seenMessageIds = new Map<string, boolean>();
|
||||
private botOpenId: string | null = null;
|
||||
/** All known bot IDs (open_id, user_id, union_id) for mention matching. */
|
||||
private botIds = new Set<string>();
|
||||
/** Track last incoming message ID per chat for typing indicator. */
|
||||
private lastIncomingMessageId = new Map<string, string>();
|
||||
/** Track active typing reaction IDs per chat for cleanup. */
|
||||
private typingReactions = new Map<string, string>();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
|
||||
const configError = this.validateConfig();
|
||||
if (configError) {
|
||||
console.warn('[feishu-adapter] Cannot start:', configError);
|
||||
return;
|
||||
}
|
||||
|
||||
const appId = getSetting('bridge_feishu_app_id') || '';
|
||||
const appSecret = getSetting('bridge_feishu_app_secret') || '';
|
||||
const domainSetting = getSetting('bridge_feishu_domain') || 'feishu';
|
||||
const domain = domainSetting === 'lark'
|
||||
? lark.Domain.Lark
|
||||
: lark.Domain.Feishu;
|
||||
|
||||
// Create REST client
|
||||
this.restClient = new lark.Client({
|
||||
appId,
|
||||
appSecret,
|
||||
domain,
|
||||
});
|
||||
|
||||
// Resolve bot identity for @mention detection
|
||||
await this.resolveBotIdentity(appId, appSecret, domain);
|
||||
|
||||
this.running = true;
|
||||
|
||||
// Create EventDispatcher and register event handlers.
|
||||
// NOTE: card.action.trigger requires HTTP webhook (not supported via WSClient).
|
||||
// Openclaw uses an HTTP server for card callbacks — CodePilot is a desktop app
|
||||
// without a public endpoint, so we rely on text-based /perm commands instead.
|
||||
const dispatcher = new lark.EventDispatcher({}).register({
|
||||
'im.message.receive_v1': async (data) => {
|
||||
await this.handleIncomingEvent(data as FeishuMessageEventData);
|
||||
},
|
||||
});
|
||||
|
||||
// Create and start WSClient
|
||||
this.wsClient = new lark.WSClient({
|
||||
appId,
|
||||
appSecret,
|
||||
domain,
|
||||
});
|
||||
this.wsClient.start({ eventDispatcher: dispatcher });
|
||||
|
||||
console.log('[feishu-adapter] Started (botOpenId:', this.botOpenId || 'unknown', ')');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
|
||||
// Close WebSocket connection (SDK exposes close())
|
||||
if (this.wsClient) {
|
||||
try {
|
||||
this.wsClient.close({ force: true });
|
||||
} catch (err) {
|
||||
console.warn('[feishu-adapter] WSClient close error:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
this.wsClient = null;
|
||||
}
|
||||
this.restClient = null;
|
||||
|
||||
// Reject all waiting consumers
|
||||
for (const waiter of this.waiters) {
|
||||
waiter(null);
|
||||
}
|
||||
this.waiters = [];
|
||||
|
||||
// Clear state
|
||||
this.seenMessageIds.clear();
|
||||
this.lastIncomingMessageId.clear();
|
||||
this.typingReactions.clear();
|
||||
|
||||
console.log('[feishu-adapter] Stopped');
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
// ── Queue ───────────────────────────────────────────────────
|
||||
|
||||
consumeOne(): Promise<InboundMessage | null> {
|
||||
const queued = this.queue.shift();
|
||||
if (queued) return Promise.resolve(queued);
|
||||
|
||||
if (!this.running) return Promise.resolve(null);
|
||||
|
||||
return new Promise<InboundMessage | null>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private enqueue(msg: InboundMessage): void {
|
||||
const waiter = this.waiters.shift();
|
||||
if (waiter) {
|
||||
waiter(msg);
|
||||
} else {
|
||||
this.queue.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typing indicator (Openclaw-style reaction) ─────────────
|
||||
|
||||
/**
|
||||
* Add a "Typing" emoji reaction to the user's message.
|
||||
* Called by bridge-manager via onMessageStart().
|
||||
*/
|
||||
onMessageStart(chatId: string): void {
|
||||
const messageId = this.lastIncomingMessageId.get(chatId);
|
||||
if (!messageId || !this.restClient) return;
|
||||
|
||||
// Fire-and-forget — typing indicator is non-critical
|
||||
this.restClient.im.messageReaction.create({
|
||||
path: { message_id: messageId },
|
||||
data: { reaction_type: { emoji_type: TYPING_EMOJI } },
|
||||
}).then((res) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const reactionId = (res as any)?.data?.reaction_id;
|
||||
if (reactionId) {
|
||||
this.typingReactions.set(chatId, reactionId);
|
||||
}
|
||||
}).catch((err) => {
|
||||
// Non-critical — don't log rate limit errors
|
||||
const code = (err as { code?: number })?.code;
|
||||
if (code !== 99991400 && code !== 99991403) {
|
||||
console.warn('[feishu-adapter] Typing indicator failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the "Typing" emoji reaction from the user's message.
|
||||
* Called by bridge-manager via onMessageEnd().
|
||||
*/
|
||||
onMessageEnd(chatId: string): void {
|
||||
const reactionId = this.typingReactions.get(chatId);
|
||||
const messageId = this.lastIncomingMessageId.get(chatId);
|
||||
if (!reactionId || !messageId || !this.restClient) return;
|
||||
|
||||
this.typingReactions.delete(chatId);
|
||||
|
||||
// Fire-and-forget — failure is fine (reaction may already be gone)
|
||||
this.restClient.im.messageReaction.delete({
|
||||
path: { message_id: messageId, reaction_id: reactionId },
|
||||
}).catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
// ── Send ────────────────────────────────────────────────────
|
||||
|
||||
async send(message: OutboundMessage): Promise<SendResult> {
|
||||
if (!this.restClient) {
|
||||
return { ok: false, error: 'Feishu client not initialized' };
|
||||
}
|
||||
|
||||
let text = message.text;
|
||||
|
||||
// Convert HTML to markdown for Feishu rendering (e.g. command responses)
|
||||
if (message.parseMode === 'HTML') {
|
||||
text = htmlToFeishuMarkdown(text);
|
||||
}
|
||||
|
||||
// Preprocess markdown for Claude responses
|
||||
if (message.parseMode === 'Markdown') {
|
||||
text = preprocessFeishuMarkdown(text);
|
||||
}
|
||||
|
||||
// If there are inline buttons (permission prompts), send card with action buttons
|
||||
if (message.inlineButtons && message.inlineButtons.length > 0) {
|
||||
return this.sendPermissionCard(message.address.chatId, text, message.inlineButtons);
|
||||
}
|
||||
|
||||
// Rendering strategy (aligned with Openclaw):
|
||||
// - Code blocks / tables → interactive card (schema 2.0 markdown)
|
||||
// - Other text → post (md tag)
|
||||
if (hasComplexMarkdown(text)) {
|
||||
return this.sendAsCard(message.address.chatId, text);
|
||||
}
|
||||
return this.sendAsPost(message.address.chatId, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text as an interactive card (schema 2.0 markdown).
|
||||
* Used for code blocks and tables — card renders them properly.
|
||||
*/
|
||||
private async sendAsCard(chatId: string, text: string): Promise<SendResult> {
|
||||
const cardContent = buildCardContent(text);
|
||||
|
||||
try {
|
||||
const res = await this.restClient!.im.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: 'interactive',
|
||||
content: cardContent,
|
||||
},
|
||||
});
|
||||
|
||||
if (res?.data?.message_id) {
|
||||
return { ok: true, messageId: res.data.message_id };
|
||||
}
|
||||
console.warn('[feishu-adapter] Card send failed:', res?.msg, res?.code);
|
||||
} catch (err) {
|
||||
console.warn('[feishu-adapter] Card send error, falling back to post:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// Fallback to post
|
||||
return this.sendAsPost(chatId, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text as a post message (msg_type: 'post') with md tag.
|
||||
* Used for simple text — renders bold, italic, inline code, links.
|
||||
*/
|
||||
private async sendAsPost(chatId: string, text: string): Promise<SendResult> {
|
||||
const postContent = buildPostContent(text);
|
||||
|
||||
try {
|
||||
const res = await this.restClient!.im.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: 'post',
|
||||
content: postContent,
|
||||
},
|
||||
});
|
||||
|
||||
if (res?.data?.message_id) {
|
||||
return { ok: true, messageId: res.data.message_id };
|
||||
}
|
||||
console.warn('[feishu-adapter] Post send failed:', res?.msg, res?.code);
|
||||
} catch (err) {
|
||||
console.warn('[feishu-adapter] Post send error, falling back to text:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// Final fallback: plain text
|
||||
try {
|
||||
const res = await this.restClient!.im.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text }),
|
||||
},
|
||||
});
|
||||
if (res?.data?.message_id) {
|
||||
return { ok: true, messageId: res.data.message_id };
|
||||
}
|
||||
return { ok: false, error: res?.msg || 'Send failed' };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Send failed' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Permission card (with real action buttons) ─────────────
|
||||
|
||||
/**
|
||||
* Send a permission card with real Feishu card action buttons.
|
||||
* Button clicks trigger card.action.trigger events handled by handleCardActionEvent().
|
||||
* Feishu card action callbacks require HTTP webhook (not supported via WSClient).
|
||||
* CodePilot is a desktop app without a public endpoint, so we send a
|
||||
* well-formatted card with /perm text commands instead of clickable buttons.
|
||||
* The user replies with the /perm command to approve/deny.
|
||||
*/
|
||||
private async sendPermissionCard(
|
||||
chatId: string,
|
||||
text: string,
|
||||
inlineButtons: import('../types').InlineButton[][],
|
||||
): Promise<SendResult> {
|
||||
if (!this.restClient) {
|
||||
return { ok: false, error: 'Feishu client not initialized' };
|
||||
}
|
||||
|
||||
// Build /perm command lines from inline buttons
|
||||
const permCommands = inlineButtons.flat().map((btn) => {
|
||||
if (btn.callbackData.startsWith('perm:')) {
|
||||
const parts = btn.callbackData.split(':');
|
||||
const action = parts[1];
|
||||
const permId = parts.slice(2).join(':');
|
||||
return `\`/perm ${action} ${permId}\``;
|
||||
}
|
||||
return btn.text;
|
||||
});
|
||||
|
||||
// Schema 2.0 card with markdown — permission info + copyable commands
|
||||
const cardContent = [
|
||||
text,
|
||||
'',
|
||||
'---',
|
||||
'**Reply with one of these commands:**',
|
||||
...permCommands,
|
||||
].join('\n');
|
||||
|
||||
const cardJson = JSON.stringify({
|
||||
schema: '2.0',
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
template: 'orange',
|
||||
title: { tag: 'plain_text', content: '🔐 Permission Required' },
|
||||
},
|
||||
body: {
|
||||
elements: [
|
||||
{ tag: 'markdown', content: cardContent },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await this.restClient.im.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: 'interactive',
|
||||
content: cardJson,
|
||||
},
|
||||
});
|
||||
if (res?.data?.message_id) {
|
||||
return { ok: true, messageId: res.data.message_id };
|
||||
}
|
||||
console.warn('[feishu-adapter] Permission card send failed:', res?.msg);
|
||||
} catch (err) {
|
||||
console.warn('[feishu-adapter] Permission card error:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// Fallback: plain text
|
||||
const plainCommands = inlineButtons.flat().map((btn) => {
|
||||
if (btn.callbackData.startsWith('perm:')) {
|
||||
const parts = btn.callbackData.split(':');
|
||||
return `/perm ${parts[1]} ${parts.slice(2).join(':')}`;
|
||||
}
|
||||
return btn.text;
|
||||
});
|
||||
const fallbackText = text + '\n\nReply with:\n' + plainCommands.join('\n');
|
||||
|
||||
try {
|
||||
const res = await this.restClient.im.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text: fallbackText }),
|
||||
},
|
||||
});
|
||||
if (res?.data?.message_id) {
|
||||
return { ok: true, messageId: res.data.message_id };
|
||||
}
|
||||
return { ok: false, error: res?.msg || 'Send failed' };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Send failed' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config & Auth ───────────────────────────────────────────
|
||||
|
||||
validateConfig(): string | null {
|
||||
const enabled = getSetting('bridge_feishu_enabled');
|
||||
if (enabled !== 'true') return 'bridge_feishu_enabled is not true';
|
||||
|
||||
const appId = getSetting('bridge_feishu_app_id');
|
||||
if (!appId) return 'bridge_feishu_app_id not configured';
|
||||
|
||||
const appSecret = getSetting('bridge_feishu_app_secret');
|
||||
if (!appSecret) return 'bridge_feishu_app_secret not configured';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
isAuthorized(userId: string, chatId: string): boolean {
|
||||
const allowedUsers = getSetting('bridge_feishu_allowed_users') || '';
|
||||
if (!allowedUsers) {
|
||||
// No restriction configured — allow all
|
||||
return true;
|
||||
}
|
||||
|
||||
const allowed = allowedUsers
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (allowed.length === 0) return true;
|
||||
|
||||
return allowed.includes(userId) || allowed.includes(chatId);
|
||||
}
|
||||
|
||||
// ── Incoming event handler ──────────────────────────────────
|
||||
|
||||
private async handleIncomingEvent(data: FeishuMessageEventData): Promise<void> {
|
||||
try {
|
||||
await this.processIncomingEvent(data);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[feishu-adapter] Unhandled error in event handler:',
|
||||
err instanceof Error ? err.stack || err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processIncomingEvent(data: FeishuMessageEventData): Promise<void> {
|
||||
const msg = data.message;
|
||||
const sender = data.sender;
|
||||
|
||||
// [P1] Filter out bot messages to prevent self-triggering loops
|
||||
if (sender.sender_type === 'bot') return;
|
||||
|
||||
// Dedup by message_id
|
||||
if (this.seenMessageIds.has(msg.message_id)) return;
|
||||
this.addToDedup(msg.message_id);
|
||||
|
||||
const chatId = msg.chat_id;
|
||||
// [P2] Complete sender ID fallback chain: open_id > user_id > union_id
|
||||
const userId = sender.sender_id?.open_id
|
||||
|| sender.sender_id?.user_id
|
||||
|| sender.sender_id?.union_id
|
||||
|| '';
|
||||
const isGroup = msg.chat_type === 'group';
|
||||
|
||||
// Authorization check
|
||||
if (!this.isAuthorized(userId, chatId)) {
|
||||
console.warn('[feishu-adapter] Unauthorized message from userId:', userId, 'chatId:', chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Group chat policy
|
||||
if (isGroup) {
|
||||
const policy = getSetting('bridge_feishu_group_policy') || 'open';
|
||||
|
||||
if (policy === 'disabled') {
|
||||
console.log('[feishu-adapter] Group message ignored (policy=disabled), chatId:', chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy === 'allowlist') {
|
||||
const allowedGroups = (getSetting('bridge_feishu_group_allow_from') || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!allowedGroups.includes(chatId)) {
|
||||
console.log('[feishu-adapter] Group message ignored (not in allowlist), chatId:', chatId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Require @mention check
|
||||
const requireMention = getSetting('bridge_feishu_require_mention') !== 'false';
|
||||
if (requireMention && !this.isBotMentioned(msg.mentions)) {
|
||||
console.log('[feishu-adapter] Group message ignored (bot not @mentioned), chatId:', chatId, 'msgId:', msg.message_id);
|
||||
try {
|
||||
insertAuditLog({
|
||||
channelType: 'feishu',
|
||||
chatId,
|
||||
direction: 'inbound',
|
||||
messageId: msg.message_id,
|
||||
summary: '[FILTERED] Group message dropped: bot not @mentioned (require_mention=true)',
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Track last message ID per chat for typing indicator
|
||||
this.lastIncomingMessageId.set(chatId, msg.message_id);
|
||||
|
||||
// Extract content based on message type
|
||||
const messageType = msg.message_type;
|
||||
let text = '';
|
||||
const attachments: FileAttachment[] = [];
|
||||
|
||||
if (messageType === 'text') {
|
||||
text = this.parseTextContent(msg.content);
|
||||
} else if (messageType === 'image') {
|
||||
// [P1] Download image with failure fallback
|
||||
console.log('[feishu-adapter] Image message received, content:', msg.content);
|
||||
const fileKey = this.extractFileKey(msg.content);
|
||||
console.log('[feishu-adapter] Extracted fileKey:', fileKey);
|
||||
if (fileKey) {
|
||||
const attachment = await this.downloadResource(msg.message_id, fileKey, 'image');
|
||||
if (attachment) {
|
||||
attachments.push(attachment);
|
||||
} else {
|
||||
text = '[image download failed]';
|
||||
try {
|
||||
insertAuditLog({
|
||||
channelType: 'feishu',
|
||||
chatId,
|
||||
direction: 'inbound',
|
||||
messageId: msg.message_id,
|
||||
summary: `[ERROR] Image download failed for key: ${fileKey}`,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
} else if (messageType === 'file' || messageType === 'audio' || messageType === 'video' || messageType === 'media') {
|
||||
// [P2] Support file/audio/video/media downloads
|
||||
const fileKey = this.extractFileKey(msg.content);
|
||||
if (fileKey) {
|
||||
const resourceType = messageType === 'audio' || messageType === 'video' || messageType === 'media'
|
||||
? messageType
|
||||
: 'file';
|
||||
const attachment = await this.downloadResource(msg.message_id, fileKey, resourceType);
|
||||
if (attachment) {
|
||||
attachments.push(attachment);
|
||||
} else {
|
||||
text = `[${messageType} download failed]`;
|
||||
try {
|
||||
insertAuditLog({
|
||||
channelType: 'feishu',
|
||||
chatId,
|
||||
direction: 'inbound',
|
||||
messageId: msg.message_id,
|
||||
summary: `[ERROR] ${messageType} download failed for key: ${fileKey}`,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
} else if (messageType === 'post') {
|
||||
// [P2] Extract text and image keys from rich text (post) messages
|
||||
const { extractedText, imageKeys } = this.parsePostContent(msg.content);
|
||||
text = extractedText;
|
||||
for (const key of imageKeys) {
|
||||
const attachment = await this.downloadResource(msg.message_id, key, 'image');
|
||||
if (attachment) {
|
||||
attachments.push(attachment);
|
||||
}
|
||||
// Don't add fallback text for individual post images — the text already carries context
|
||||
}
|
||||
} else {
|
||||
// Unsupported type — log and skip
|
||||
console.log(`[feishu-adapter] Unsupported message type: ${messageType}, msgId: ${msg.message_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip @mention markers from text
|
||||
text = this.stripMentionMarkers(text);
|
||||
|
||||
if (!text.trim() && attachments.length === 0) return;
|
||||
|
||||
const timestamp = parseInt(msg.create_time, 10) || Date.now();
|
||||
const address = {
|
||||
channelType: 'feishu' as const,
|
||||
chatId,
|
||||
userId,
|
||||
};
|
||||
|
||||
// [P1] Check for /perm text command (permission approval fallback)
|
||||
const trimmedText = text.trim();
|
||||
if (trimmedText.startsWith('/perm ')) {
|
||||
const permParts = trimmedText.split(/\s+/);
|
||||
// /perm <action> <permId>
|
||||
if (permParts.length >= 3) {
|
||||
const action = permParts[1]; // allow / allow_session / deny
|
||||
const permId = permParts.slice(2).join(' ');
|
||||
const callbackData = `perm:${action}:${permId}`;
|
||||
|
||||
const inbound: InboundMessage = {
|
||||
messageId: msg.message_id,
|
||||
address,
|
||||
text: trimmedText,
|
||||
timestamp,
|
||||
callbackData,
|
||||
};
|
||||
this.enqueue(inbound);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const inbound: InboundMessage = {
|
||||
messageId: msg.message_id,
|
||||
address,
|
||||
text: text.trim(),
|
||||
timestamp,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
|
||||
// Audit log
|
||||
try {
|
||||
const summary = attachments.length > 0
|
||||
? `[${attachments.length} attachment(s)] ${text.slice(0, 150)}`
|
||||
: text.slice(0, 200);
|
||||
insertAuditLog({
|
||||
channelType: 'feishu',
|
||||
chatId,
|
||||
direction: 'inbound',
|
||||
messageId: msg.message_id,
|
||||
summary,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
|
||||
this.enqueue(inbound);
|
||||
}
|
||||
|
||||
// ── Content parsing ─────────────────────────────────────────
|
||||
|
||||
private parseTextContent(content: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.text || '';
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file key from message content JSON.
|
||||
* Handles multiple key names: image_key, file_key, imageKey, fileKey.
|
||||
*/
|
||||
private extractFileKey(content: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.image_key || parsed.file_key || parsed.imageKey || parsed.fileKey || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse rich text (post) content.
|
||||
* Extracts plain text from text elements and image keys from img elements.
|
||||
*/
|
||||
private parsePostContent(content: string): { extractedText: string; imageKeys: string[] } {
|
||||
const imageKeys: string[] = [];
|
||||
const textParts: string[] = [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
// Post content structure: { title, content: [[{tag, text/image_key}]] }
|
||||
const title = parsed.title;
|
||||
if (title) textParts.push(title);
|
||||
|
||||
const paragraphs = parsed.content;
|
||||
if (Array.isArray(paragraphs)) {
|
||||
for (const paragraph of paragraphs) {
|
||||
if (!Array.isArray(paragraph)) continue;
|
||||
for (const element of paragraph) {
|
||||
if (element.tag === 'text' && element.text) {
|
||||
textParts.push(element.text);
|
||||
} else if (element.tag === 'a' && element.text) {
|
||||
textParts.push(element.text);
|
||||
} else if (element.tag === 'at' && element.user_id) {
|
||||
// Mention in post — handled by isBotMentioned for group policy
|
||||
} else if (element.tag === 'img') {
|
||||
const key = element.image_key || element.file_key || element.imageKey;
|
||||
if (key) imageKeys.push(key);
|
||||
}
|
||||
}
|
||||
textParts.push('\n');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse post content
|
||||
}
|
||||
|
||||
return { extractedText: textParts.join('').trim(), imageKeys };
|
||||
}
|
||||
|
||||
// ── Bot identity ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve bot identity via the Feishu REST API /bot/v3/info/.
|
||||
* Collects all available bot IDs for comprehensive mention matching.
|
||||
*/
|
||||
private async resolveBotIdentity(
|
||||
appId: string,
|
||||
appSecret: string,
|
||||
domain: lark.Domain,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const baseUrl = domain === lark.Domain.Lark
|
||||
? 'https://open.larksuite.com'
|
||||
: 'https://open.feishu.cn';
|
||||
|
||||
const tokenRes = await fetch(`${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const tokenData = await tokenRes.json();
|
||||
if (!tokenData.tenant_access_token) {
|
||||
console.warn('[feishu-adapter] Failed to get tenant access token');
|
||||
return;
|
||||
}
|
||||
|
||||
const botRes = await fetch(`${baseUrl}/open-apis/bot/v3/info/`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${tokenData.tenant_access_token}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const botData = await botRes.json();
|
||||
if (botData?.bot?.open_id) {
|
||||
this.botOpenId = botData.bot.open_id;
|
||||
this.botIds.add(botData.bot.open_id);
|
||||
}
|
||||
// Also record app_id-based IDs if available
|
||||
if (botData?.bot?.bot_id) {
|
||||
this.botIds.add(botData.bot.bot_id);
|
||||
}
|
||||
if (!this.botOpenId) {
|
||||
console.warn('[feishu-adapter] Could not resolve bot open_id');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[feishu-adapter] Failed to resolve bot identity:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── @Mention detection ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* [P2] Check if bot is mentioned — matches against open_id, user_id, union_id.
|
||||
*/
|
||||
private isBotMentioned(
|
||||
mentions?: FeishuMessageEventData['message']['mentions'],
|
||||
): boolean {
|
||||
if (!mentions || this.botIds.size === 0) return false;
|
||||
return mentions.some((m) => {
|
||||
const ids = [m.id.open_id, m.id.user_id, m.id.union_id].filter(Boolean) as string[];
|
||||
return ids.some((id) => this.botIds.has(id));
|
||||
});
|
||||
}
|
||||
|
||||
private stripMentionMarkers(text: string): string {
|
||||
// Feishu uses @_user_N placeholders for mentions
|
||||
return text.replace(/@_user_\d+/g, '').trim();
|
||||
}
|
||||
|
||||
// ── Resource download ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Download a message resource (image/file/audio/video) via SDK.
|
||||
* Returns null on failure (caller decides fallback behavior).
|
||||
*/
|
||||
private async downloadResource(
|
||||
messageId: string,
|
||||
fileKey: string,
|
||||
resourceType: string,
|
||||
): Promise<FileAttachment | null> {
|
||||
if (!this.restClient) return null;
|
||||
|
||||
try {
|
||||
console.log(`[feishu-adapter] Downloading resource: type=${resourceType}, key=${fileKey}, msgId=${messageId}`);
|
||||
|
||||
const res = await this.restClient.im.messageResource.get({
|
||||
path: {
|
||||
message_id: messageId,
|
||||
file_key: fileKey,
|
||||
},
|
||||
params: {
|
||||
type: resourceType === 'image' ? 'image' : 'file',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
console.warn('[feishu-adapter] messageResource.get returned null/undefined');
|
||||
return null;
|
||||
}
|
||||
|
||||
// SDK returns { writeFile, getReadableStream, headers }
|
||||
// Try stream approach first, fall back to writeFile + read if stream fails
|
||||
let buffer: Buffer;
|
||||
|
||||
try {
|
||||
const readable = res.getReadableStream();
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for await (const chunk of readable) {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalSize += buf.length;
|
||||
if (totalSize > MAX_FILE_SIZE) {
|
||||
console.warn(`[feishu-adapter] Resource too large (>${MAX_FILE_SIZE} bytes), key: ${fileKey}`);
|
||||
return null;
|
||||
}
|
||||
chunks.push(buf);
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} catch (streamErr) {
|
||||
// Stream approach failed — fall back to writeFile + read
|
||||
console.warn('[feishu-adapter] Stream read failed, falling back to writeFile:', streamErr instanceof Error ? streamErr.message : streamErr);
|
||||
|
||||
const fs = await import('fs');
|
||||
const os = await import('os');
|
||||
const path = await import('path');
|
||||
const tmpPath = path.join(os.tmpdir(), `feishu-dl-${crypto.randomUUID()}`);
|
||||
try {
|
||||
await res.writeFile(tmpPath);
|
||||
buffer = fs.readFileSync(tmpPath);
|
||||
if (buffer.length > MAX_FILE_SIZE) {
|
||||
console.warn(`[feishu-adapter] Resource too large (>${MAX_FILE_SIZE} bytes), key: ${fileKey}`);
|
||||
return null;
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
console.warn('[feishu-adapter] Downloaded resource is empty, key:', fileKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
const base64 = buffer.toString('base64');
|
||||
const id = crypto.randomUUID();
|
||||
const mimeType = MIME_BY_TYPE[resourceType] || 'application/octet-stream';
|
||||
const ext = resourceType === 'image' ? 'png'
|
||||
: resourceType === 'audio' ? 'ogg'
|
||||
: resourceType === 'video' ? 'mp4'
|
||||
: 'bin';
|
||||
|
||||
console.log(`[feishu-adapter] Resource downloaded: ${buffer.length} bytes, key=${fileKey}`);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: `${fileKey}.${ext}`,
|
||||
type: mimeType,
|
||||
size: buffer.length,
|
||||
data: base64,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[feishu-adapter] Resource download failed (type=${resourceType}, key=${fileKey}):`,
|
||||
err instanceof Error ? err.stack || err.message : err,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ───────────────────────────────────────────────
|
||||
|
||||
private addToDedup(messageId: string): void {
|
||||
this.seenMessageIds.set(messageId, true);
|
||||
|
||||
// LRU eviction: remove oldest entries when exceeding limit
|
||||
if (this.seenMessageIds.size > DEDUP_MAX) {
|
||||
const excess = this.seenMessageIds.size - DEDUP_MAX;
|
||||
let removed = 0;
|
||||
for (const key of this.seenMessageIds.keys()) {
|
||||
if (removed >= excess) break;
|
||||
this.seenMessageIds.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
import { registerAdapterFactory } from '../channel-adapter';
|
||||
import { ChannelPluginAdapter } from '../../channels/channel-plugin-adapter';
|
||||
import { FeishuChannelPlugin } from '../../channels/feishu';
|
||||
|
||||
// Self-register so bridge-manager can create FeishuAdapter via the registry.
|
||||
registerAdapterFactory('feishu', () => new FeishuAdapter());
|
||||
registerAdapterFactory('feishu', () =>
|
||||
new ChannelPluginAdapter(new FeishuChannelPlugin())
|
||||
);
|
||||
@@ -29,6 +29,16 @@ import {
|
||||
sanitizeInput,
|
||||
validateMode,
|
||||
} from './security/validators';
|
||||
import { ChannelPluginAdapter } from '../channels/channel-plugin-adapter';
|
||||
|
||||
/**
|
||||
* Extract the real platform chat_id from a potentially synthetic thread-session address.
|
||||
* Thread-session mode encodes addresses as `{real_chat_id}:thread:{root_id}`.
|
||||
*/
|
||||
function extractRealChatId(chatId: string): string {
|
||||
const threadIdx = chatId.indexOf(':thread:');
|
||||
return threadIdx >= 0 ? chatId.slice(0, threadIdx) : chatId;
|
||||
}
|
||||
|
||||
const GLOBAL_KEY = '__bridge_manager__';
|
||||
|
||||
@@ -369,6 +379,15 @@ export function registerAdapter(adapter: BaseChannelAdapter): void {
|
||||
state.adapters.set(adapter.channelType, adapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a running adapter by channel type.
|
||||
* Returns null if the adapter is not registered.
|
||||
*/
|
||||
export function getAdapter(channelType: string): BaseChannelAdapter | null {
|
||||
const state = getState();
|
||||
return state.adapters.get(channelType) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the event loop for a single adapter.
|
||||
* Messages for different sessions are dispatched concurrently;
|
||||
@@ -513,51 +532,90 @@ async function handleMessage(
|
||||
};
|
||||
}
|
||||
|
||||
// ── Card streaming setup (Feishu) ──────────────────────────────
|
||||
let cardController: import('../channels/types').CardStreamController | null = null;
|
||||
let cardMessageId: string | null = null;
|
||||
let cardCreating = false;
|
||||
let cardBufferedText = '';
|
||||
let cardFinalized = false;
|
||||
|
||||
if (!previewState && adapter.getCardStreamController) {
|
||||
cardController = adapter.getCardStreamController();
|
||||
}
|
||||
|
||||
const streamCfg = previewState ? getStreamConfig(adapter.channelType) : null;
|
||||
|
||||
// Build the onPartialText callback (or undefined if preview not supported)
|
||||
const onPartialText = (previewState && streamCfg) ? (fullText: string) => {
|
||||
const ps = previewState!;
|
||||
const cfg = streamCfg!;
|
||||
if (ps.degraded) return;
|
||||
// Build the onPartialText callback — preview streaming OR card streaming
|
||||
let onPartialText: ((fullText: string) => void) | undefined;
|
||||
|
||||
// Truncate to maxChars + ellipsis
|
||||
ps.pendingText = fullText.length > cfg.maxChars
|
||||
? fullText.slice(0, cfg.maxChars) + '...'
|
||||
: fullText;
|
||||
if (previewState && streamCfg) {
|
||||
// Preview-based streaming (Telegram, etc.)
|
||||
const ps = previewState;
|
||||
const cfg = streamCfg;
|
||||
onPartialText = (fullText: string) => {
|
||||
if (ps.degraded) return;
|
||||
|
||||
const delta = ps.pendingText.length - ps.lastSentText.length;
|
||||
const elapsed = Date.now() - ps.lastSentAt;
|
||||
ps.pendingText = fullText.length > cfg.maxChars
|
||||
? fullText.slice(0, cfg.maxChars) + '...'
|
||||
: fullText;
|
||||
|
||||
if (delta < cfg.minDeltaChars && ps.lastSentAt > 0) {
|
||||
// Not enough new content — schedule trailing-edge timer if not already set
|
||||
if (!ps.throttleTimer) {
|
||||
ps.throttleTimer = setTimeout(() => {
|
||||
ps.throttleTimer = null;
|
||||
if (!ps.degraded) flushPreview(adapter, ps, cfg);
|
||||
}, cfg.intervalMs);
|
||||
const delta = ps.pendingText.length - ps.lastSentText.length;
|
||||
const elapsed = Date.now() - ps.lastSentAt;
|
||||
|
||||
if (delta < cfg.minDeltaChars && ps.lastSentAt > 0) {
|
||||
if (!ps.throttleTimer) {
|
||||
ps.throttleTimer = setTimeout(() => {
|
||||
ps.throttleTimer = null;
|
||||
if (!ps.degraded) flushPreview(adapter, ps, cfg);
|
||||
}, cfg.intervalMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (elapsed < cfg.intervalMs && ps.lastSentAt > 0) {
|
||||
// Too soon — schedule trailing-edge timer to ensure latest text is sent
|
||||
if (!ps.throttleTimer) {
|
||||
ps.throttleTimer = setTimeout(() => {
|
||||
ps.throttleTimer = null;
|
||||
if (!ps.degraded) flushPreview(adapter, ps, cfg);
|
||||
}, cfg.intervalMs - elapsed);
|
||||
if (elapsed < cfg.intervalMs && ps.lastSentAt > 0) {
|
||||
if (!ps.throttleTimer) {
|
||||
ps.throttleTimer = setTimeout(() => {
|
||||
ps.throttleTimer = null;
|
||||
if (!ps.degraded) flushPreview(adapter, ps, cfg);
|
||||
}, cfg.intervalMs - elapsed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending trailing-edge timer and flush immediately
|
||||
if (ps.throttleTimer) {
|
||||
clearTimeout(ps.throttleTimer);
|
||||
ps.throttleTimer = null;
|
||||
}
|
||||
flushPreview(adapter, ps, cfg);
|
||||
} : undefined;
|
||||
if (ps.throttleTimer) {
|
||||
clearTimeout(ps.throttleTimer);
|
||||
ps.throttleTimer = null;
|
||||
}
|
||||
flushPreview(adapter, ps, cfg);
|
||||
};
|
||||
} else if (cardController) {
|
||||
// Card-based streaming (Feishu)
|
||||
onPartialText = (fullText: string) => {
|
||||
if (cardCreating) {
|
||||
cardBufferedText = fullText;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cardMessageId) {
|
||||
// First call — create the card
|
||||
cardCreating = true;
|
||||
cardBufferedText = fullText;
|
||||
cardController!.create(msg.address.chatId, fullText, msg.messageId).then((msgId) => {
|
||||
cardCreating = false;
|
||||
cardMessageId = msgId || null;
|
||||
// Flush any buffered text that arrived during creation
|
||||
if (cardMessageId && cardBufferedText && cardBufferedText !== fullText) {
|
||||
cardController!.update(cardMessageId, cardBufferedText).catch(() => {});
|
||||
}
|
||||
}).catch(() => {
|
||||
cardCreating = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
cardController!.update(cardMessageId, fullText).catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass permission callback so requests are forwarded to IM immediately
|
||||
@@ -580,7 +638,13 @@ async function handleMessage(
|
||||
|
||||
// Send response text — render via channel-appropriate format
|
||||
if (result.responseText) {
|
||||
await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId);
|
||||
if (cardController && cardMessageId) {
|
||||
// Finalize streaming card with final content
|
||||
await cardController.finalize(cardMessageId, result.responseText);
|
||||
cardFinalized = true;
|
||||
} else {
|
||||
await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId);
|
||||
}
|
||||
} else if (result.hasError) {
|
||||
const errorResponse: OutboundMessage = {
|
||||
address: msg.address,
|
||||
@@ -614,6 +678,11 @@ async function handleMessage(
|
||||
adapter.endPreview?.(msg.address.chatId, previewState.draftId);
|
||||
}
|
||||
|
||||
// Clean up card streaming state
|
||||
if (cardController && cardMessageId && !cardFinalized) {
|
||||
cardController.finalize(cardMessageId, '⚠️ Response interrupted.').catch(() => {});
|
||||
}
|
||||
|
||||
state.activeTasks.delete(binding.codepilotSessionId);
|
||||
// Notify adapter that message processing ended
|
||||
adapter.onMessageEnd?.(msg.address.chatId);
|
||||
@@ -674,6 +743,8 @@ async function handleCommand(
|
||||
'/sessions - List recent sessions',
|
||||
'/stop - Stop current session',
|
||||
'/perm allow|allow_session|deny <id> - Respond to permission',
|
||||
'/history [count] - Show recent messages',
|
||||
'/search <keyword> - Search messages',
|
||||
'/help - Show this help',
|
||||
].join('\n');
|
||||
break;
|
||||
@@ -800,6 +871,94 @@ async function handleCommand(
|
||||
break;
|
||||
}
|
||||
|
||||
case '/history': {
|
||||
// Fetch recent messages from the current chat
|
||||
if (!(adapter instanceof ChannelPluginAdapter)) {
|
||||
response = 'History is not supported for this channel type.';
|
||||
break;
|
||||
}
|
||||
const plugin = adapter.getPlugin();
|
||||
if (!plugin.getCardStreamController && !(plugin as any).meta?.channelType) {
|
||||
response = 'History is not available.';
|
||||
break;
|
||||
}
|
||||
// Use message-actions if the plugin has Feishu-type capabilities
|
||||
try {
|
||||
const { readMessages } = await import('../channels/feishu/message-actions');
|
||||
const restClient = (plugin as any).gateway?.getRestClient?.();
|
||||
if (!restClient) {
|
||||
response = 'Channel not connected.';
|
||||
break;
|
||||
}
|
||||
const pageSize = parseInt(args, 10) || 10;
|
||||
const realChatId = extractRealChatId(msg.address.chatId);
|
||||
const result = await readMessages(restClient, realChatId, { pageSize });
|
||||
if (result.items.length === 0) {
|
||||
response = 'No messages found.';
|
||||
} else {
|
||||
const lines = [`<b>Recent ${result.items.length} messages:</b>`, ''];
|
||||
for (const item of result.items) {
|
||||
const time = item.createTime ? new Date(parseInt(item.createTime, 10) * 1000).toLocaleString() : '?';
|
||||
let content = '';
|
||||
try {
|
||||
const parsed = JSON.parse(item.content);
|
||||
content = (parsed.text ?? '').slice(0, 80);
|
||||
} catch {
|
||||
content = item.content.slice(0, 80);
|
||||
}
|
||||
lines.push(`[${time}] ${escapeHtml(content)}`);
|
||||
}
|
||||
if (result.hasMore) lines.push('\n<i>(more messages available)</i>');
|
||||
response = lines.join('\n');
|
||||
}
|
||||
} catch (err) {
|
||||
response = `Failed to fetch history: ${err instanceof Error ? escapeHtml(err.message) : 'unknown error'}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case '/search': {
|
||||
if (!args) {
|
||||
response = 'Usage: /search <keyword>';
|
||||
break;
|
||||
}
|
||||
if (!(adapter instanceof ChannelPluginAdapter)) {
|
||||
response = 'Search is not supported for this channel type.';
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const { searchMessages } = await import('../channels/feishu/message-actions');
|
||||
const plugin = adapter.getPlugin();
|
||||
const restClient = (plugin as any).gateway?.getRestClient?.();
|
||||
if (!restClient) {
|
||||
response = 'Channel not connected.';
|
||||
break;
|
||||
}
|
||||
const realChatId = extractRealChatId(msg.address.chatId);
|
||||
const result = await searchMessages(restClient, realChatId, args, { pageSize: 10 });
|
||||
if (result.items.length === 0) {
|
||||
response = `No messages matching "<b>${escapeHtml(args)}</b>".`;
|
||||
} else {
|
||||
const lines = [`<b>${result.items.length} result(s) for "${escapeHtml(args)}":</b>`, ''];
|
||||
for (const item of result.items) {
|
||||
const time = item.createTime ? new Date(parseInt(item.createTime, 10) * 1000).toLocaleString() : '?';
|
||||
let content = '';
|
||||
try {
|
||||
const parsed = JSON.parse(item.content);
|
||||
content = (parsed.text ?? '').slice(0, 100);
|
||||
} catch {
|
||||
content = item.content.slice(0, 100);
|
||||
}
|
||||
lines.push(`[${time}] ${escapeHtml(content)}`);
|
||||
}
|
||||
response = lines.join('\n');
|
||||
}
|
||||
} catch (err) {
|
||||
response = `Search failed: ${err instanceof Error ? escapeHtml(err.message) : 'unknown error'}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case '/help':
|
||||
response = [
|
||||
'<b>CodePilot Bridge Commands</b>',
|
||||
@@ -812,6 +971,8 @@ async function handleCommand(
|
||||
'/sessions - List recent sessions',
|
||||
'/stop - Stop current session',
|
||||
'/perm allow|allow_session|deny <id> - Respond to permission request',
|
||||
'/history [count] - Show recent messages',
|
||||
'/search <keyword> - Search messages',
|
||||
'/help - Show this help',
|
||||
].join('\n');
|
||||
break;
|
||||
|
||||
@@ -96,6 +96,12 @@ export abstract class BaseChannelAdapter {
|
||||
* normal delivery path, so this is typically a no-op.
|
||||
*/
|
||||
endPreview?(_chatId: string, _draftId: number): void;
|
||||
|
||||
/**
|
||||
* Get a card stream controller for streaming card updates.
|
||||
* Returns null if not supported by this adapter.
|
||||
*/
|
||||
getCardStreamController?(): import('../channels/types').CardStreamController | null;
|
||||
}
|
||||
|
||||
// ── Adapter Registry ────────────────────────────────────────────
|
||||
|
||||
@@ -37,10 +37,25 @@ function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
|
||||
};
|
||||
const userConfig = readJson(path.join(os.homedir(), '.claude.json'));
|
||||
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'));
|
||||
// Also read project-level .mcp.json
|
||||
const projectMcp = readJson(path.join(process.cwd(), '.mcp.json'));
|
||||
const merged = {
|
||||
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
...((projectMcp.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
};
|
||||
// Resolve ${...} placeholders in env values against DB settings
|
||||
for (const server of Object.values(merged)) {
|
||||
if (server.env) {
|
||||
for (const [key, value] of Object.entries(server.env)) {
|
||||
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
|
||||
const settingKey = value.slice(2, -1);
|
||||
const resolved = getSetting(settingKey);
|
||||
server.env[key] = resolved || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
|
||||
@@ -698,6 +698,19 @@ function migrateDb(db: Database.Database): void {
|
||||
if (permLinkColNames.length > 0 && !permLinkColNames.includes('resolved')) {
|
||||
db.exec("ALTER TABLE channel_permission_links ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
// Channel configs table (structured config for channel plugins)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS channel_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL DEFAULT 'default',
|
||||
config_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_type, account_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
Reference in new issue
Block a user