chore: restore feishu worktree snapshot

This commit is contained in:
7418 committed 2026-03-13 18:29:20 +08:00
1 parent e6d707fe21
commit 16d2f1af5d
14 files changed
+1314 -990

No files matched your search

+10
View File
@@ -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
View File
@@ -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`
+38
View File
@@ -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 清单` — 待后续实施
+879 -1
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -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 {
+16 -1
View File
@@ -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;
+47
View File
@@ -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',
+47
View File
@@ -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': '远程桥接',
+10 -945
View File
@@ -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())
);
+198 -37
View File
@@ -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 &lt;id&gt; - Respond to permission',
'/history [count] - Show recent messages',
'/search &lt;keyword&gt; - 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 &lt;keyword&gt;';
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 &lt;id&gt; - Respond to permission request',
'/history [count] - Show recent messages',
'/search &lt;keyword&gt; - Search messages',
'/help - Show this help',
].join('\n');
break;
+6
View File
@@ -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 ────────────────────────────────────────────
+15
View File
@@ -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;
+13
View File
@@ -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)
);
`);
}
// ==========================================