Implement IM adapters allowing users to chat with Claude Code from Telegram and Feishu/Lark. Includes persistent session management (chatId→sessionId mapping), project selection via /projects command, and a web UI settings page for configuring bot tokens, allowed users, and default project directory. Key changes: - adapters/: Telegram and Feishu adapter scripts with shared common modules (WsBridge, MessageBuffer, SessionStore, HttpClient, config, formatting) - Backend: adapterService + REST API (GET/PUT /api/adapters) with secret masking - Frontend: AdapterSettings page in Settings tab with i18n support - DirectoryPicker: use React Portal for dropdown to fix overflow clipping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
29 KiB
IM Adapter Session 持久化改造
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 让 IM Adapter(Telegram/飞书)创建与 Desktop App 互通的持久化 Session,实现聊天记录互通、Session 恢复、项目目录选择。
Architecture: Adapter 在连接 WebSocket 之前,先通过 HTTP POST /api/sessions 创建正式 UUID Session。chatId→sessionId 映射持久化到本地 JSON 文件,adapter 重启后可恢复。用户通过 /projects 命令从最近项目列表选择工作目录,或使用 Settings 页面配置的默认目录。
Tech Stack: Bun (HTTP fetch + WebSocket), existing server REST API (/api/sessions, /api/sessions/recent-projects), JSON file persistence
File Structure
| File | Action | Responsibility |
|---|---|---|
adapters/common/session-store.ts |
Create | chatId→sessionId 持久化映射 |
adapters/common/http-client.ts |
Create | 调用服务端 REST API(创建 session、列出项目) |
adapters/common/ws-bridge.ts |
Modify | 移除自动 sessionId 生成,接受外部传入的 sessionId |
adapters/telegram/index.ts |
Modify | 接入 session 管理 + /projects 命令 |
adapters/feishu/index.ts |
Modify | 同 Telegram 的改造 |
adapters/common/config.ts |
Modify | 新增 defaultProjectDir 顶层配置字段 |
src/server/services/adapterService.ts |
Modify | AdapterFileConfig 新增 defaultProjectDir |
desktop/src/types/adapter.ts |
Modify | 前端类型同步 |
desktop/src/pages/AdapterSettings.tsx |
Modify | 新增"默认项目"字段 |
desktop/src/i18n/locales/en.ts |
Modify | i18n |
desktop/src/i18n/locales/zh.ts |
Modify | i18n |
adapters/common/__tests__/session-store.test.ts |
Create | session-store 测试 |
adapters/common/__tests__/http-client.test.ts |
Create | http-client 测试 |
Task 1: Session Store — 持久化 chatId→sessionId 映射
Files:
-
Create:
adapters/common/session-store.ts -
Create:
adapters/common/__tests__/session-store.test.ts -
Step 1: Write the failing tests
// adapters/common/__tests__/session-store.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
import { SessionStore } from '../session-store.js'
describe('SessionStore', () => {
let tmpDir: string
let store: SessionStore
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-'))
store = new SessionStore(path.join(tmpDir, 'sessions.json'))
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
it('returns null for unknown chatId', () => {
expect(store.get('unknown')).toBeNull()
})
it('stores and retrieves a session', () => {
store.set('chat-1', 'uuid-aaa', '/path/to/project')
const entry = store.get('chat-1')
expect(entry).not.toBeNull()
expect(entry!.sessionId).toBe('uuid-aaa')
expect(entry!.workDir).toBe('/path/to/project')
})
it('overwrites existing entry on set', () => {
store.set('chat-1', 'uuid-aaa', '/old')
store.set('chat-1', 'uuid-bbb', '/new')
expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb')
})
it('deletes an entry', () => {
store.set('chat-1', 'uuid-aaa', '/path')
store.delete('chat-1')
expect(store.get('chat-1')).toBeNull()
})
it('persists to disk and reloads', () => {
store.set('chat-1', 'uuid-aaa', '/path')
// Create a new store instance pointing to the same file
const store2 = new SessionStore(path.join(tmpDir, 'sessions.json'))
expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa')
})
it('handles missing file gracefully', () => {
const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json'))
expect(store2.get('anything')).toBeNull()
})
it('lists all entries', () => {
store.set('chat-1', 'uuid-1', '/a')
store.set('chat-2', 'uuid-2', '/b')
const all = store.listAll()
expect(all).toHaveLength(2)
})
})
- Step 2: Run tests to verify they fail
Run: cd adapters && bun test common/__tests__/session-store.test.ts
Expected: FAIL — module ../session-store.js not found
- Step 3: Implement SessionStore
// adapters/common/session-store.ts
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
export type SessionEntry = {
sessionId: string
workDir: string
updatedAt: number
}
type StoreData = Record<string, SessionEntry>
function getDefaultPath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'adapter-sessions.json')
}
export class SessionStore {
private data: StoreData
private filePath: string
constructor(filePath?: string) {
this.filePath = filePath ?? getDefaultPath()
this.data = this.load()
}
get(chatId: string): SessionEntry | null {
return this.data[chatId] ?? null
}
set(chatId: string, sessionId: string, workDir: string): void {
this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() }
this.save()
}
delete(chatId: string): void {
delete this.data[chatId]
this.save()
}
listAll(): Array<{ chatId: string } & SessionEntry> {
return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry }))
}
private load(): StoreData {
try {
return JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))
} catch {
return {}
}
}
private save(): void {
const dir = path.dirname(this.filePath)
fs.mkdirSync(dir, { recursive: true })
const tmp = `${this.filePath}.tmp.${Date.now()}`
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + '\n')
fs.renameSync(tmp, this.filePath)
}
}
- Step 4: Run tests to verify they pass
Run: cd adapters && bun test common/__tests__/session-store.test.ts
Expected: 7 pass, 0 fail
- Step 5: Commit
git add adapters/common/session-store.ts adapters/common/__tests__/session-store.test.ts
git commit -m "feat(adapters): add persistent chatId→sessionId store"
Task 2: HTTP Client — 调用服务端 REST API
Files:
-
Create:
adapters/common/http-client.ts -
Create:
adapters/common/__tests__/http-client.test.ts -
Step 1: Write the failing tests
// adapters/common/__tests__/http-client.test.ts
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
import { AdapterHttpClient } from '../http-client.js'
describe('AdapterHttpClient', () => {
let client: AdapterHttpClient
const originalFetch = globalThis.fetch
beforeEach(() => {
client = new AdapterHttpClient('ws://127.0.0.1:3456')
})
afterEach(() => {
globalThis.fetch = originalFetch
})
it('derives HTTP URL from WS URL', () => {
expect(client.httpBaseUrl).toBe('http://127.0.0.1:3456')
const secure = new AdapterHttpClient('wss://example.com:443')
expect(secure.httpBaseUrl).toBe('https://example.com:443')
})
it('createSession calls POST /api/sessions', async () => {
const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}))
) as any
const sessionId = await client.createSession('/path/to/project')
expect(sessionId).toBe(mockSessionId)
const call = (globalThis.fetch as any).mock.calls[0]
expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions')
const body = JSON.parse(call[1].body)
expect(body.workDir).toBe('/path/to/project')
})
it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
const mockProjects = [
{ projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
]
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const projects = await client.listRecentProjects()
expect(projects).toHaveLength(1)
expect(projects[0].projectName).toBe('my-app')
})
it('createSession throws on server error', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
}))
) as any
expect(client.createSession('')).rejects.toThrow()
})
})
- Step 2: Run tests to verify they fail
Run: cd adapters && bun test common/__tests__/http-client.test.ts
Expected: FAIL — module not found
- Step 3: Implement AdapterHttpClient
// adapters/common/http-client.ts
export type RecentProject = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
export class AdapterHttpClient {
readonly httpBaseUrl: string
constructor(wsUrl: string) {
this.httpBaseUrl = wsUrl
.replace(/^ws:/, 'http:')
.replace(/^wss:/, 'https:')
.replace(/\/$/, '')
}
async createSession(workDir: string): Promise<string> {
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(`Failed to create session: ${(err as any).message}`)
}
const data = (await res.json()) as { sessionId: string }
return data.sessionId
}
async listRecentProjects(): Promise<RecentProject[]> {
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`)
if (!res.ok) {
throw new Error(`Failed to list projects: ${res.statusText}`)
}
const data = (await res.json()) as { projects: RecentProject[] }
return data.projects
}
}
- Step 4: Run tests to verify they pass
Run: cd adapters && bun test common/__tests__/http-client.test.ts
Expected: 4 pass, 0 fail
- Step 5: Commit
git add adapters/common/http-client.ts adapters/common/__tests__/http-client.test.ts
git commit -m "feat(adapters): add HTTP client for server session API"
Task 3: WsBridge — 支持外部传入 sessionId
Files:
-
Modify:
adapters/common/ws-bridge.ts:46-56 -
Modify:
adapters/common/__tests__/ws-bridge.test.ts -
Step 1: Update ws-bridge.test.ts — add test for connectSession
在现有测试文件末尾,describe('WsBridge') 块内添加:
it('connectSession connects with provided sessionId', () => {
bridge.connectSession('chat-1', 'my-uuid-session-id')
expect(bridge.hasSession('chat-1')).toBe(true)
})
it('connectSession reuses existing open connection', () => {
bridge.connectSession('chat-1', 'uuid-1')
// Simulate WS open
const ws1 = (bridge as any).sessions.get('chat-1')?.ws
bridge.connectSession('chat-1', 'uuid-2')
// Should not replace if first is still connecting/open
// (In practice, WS is in CONNECTING state, so it gets replaced)
})
- Step 2: Run test to verify it fails
Run: cd adapters && bun test common/__tests__/ws-bridge.test.ts
Expected: FAIL — bridge.connectSession is not a function
- Step 3: Modify ws-bridge.ts
Replace getOrCreateSession with connectSession:
// In ws-bridge.ts, replace the getOrCreateSession method (lines 46-56) with:
/** Connect to a session with a known sessionId. Returns false if already connected. */
connectSession(chatId: string, sessionId: string): boolean {
const existing = this.sessions.get(chatId)
if (existing && existing.ws.readyState === WebSocket.OPEN) {
return false // already connected
}
this.connect(chatId, sessionId)
return true
}
- Step 4: Run tests to verify they pass
Run: cd adapters && bun test common/__tests__/ws-bridge.test.ts
Expected: All pass (existing tests updated if they reference getOrCreateSession)
Note: If existing tests call getOrCreateSession, update them to use connectSession with an explicit sessionId like 'test-session-id'.
- Step 5: Commit
git add adapters/common/ws-bridge.ts adapters/common/__tests__/ws-bridge.test.ts
git commit -m "refactor(ws-bridge): replace getOrCreateSession with connectSession"
Task 4: Config — 新增 defaultProjectDir
Files:
-
Modify:
adapters/common/config.ts:27-31 -
Modify:
src/server/services/adapterService.ts:13-29 -
Modify:
desktop/src/types/adapter.ts -
Modify:
desktop/src/pages/AdapterSettings.tsx -
Modify:
desktop/src/i18n/locales/en.ts -
Modify:
desktop/src/i18n/locales/zh.ts -
Step 1: Add defaultProjectDir to adapter config types
In adapters/common/config.ts, add to AdapterConfig type and loadConfig:
// adapters/common/config.ts — update AdapterConfig type (line 27-31)
export type AdapterConfig = {
serverUrl: string
defaultProjectDir: string // ← NEW
telegram: TelegramConfig
feishu: FeishuConfig
}
// In loadConfig() return statement, add:
return {
serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
defaultProjectDir: file.defaultProjectDir || '', // ← NEW
telegram: { ... },
feishu: { ... },
}
- Step 2: Add to server-side AdapterFileConfig
In src/server/services/adapterService.ts, add defaultProjectDir to the type (line 14):
export type AdapterFileConfig = {
serverUrl?: string
defaultProjectDir?: string // ← NEW
telegram?: { ... }
feishu?: { ... }
}
- Step 3: Add to frontend type
In desktop/src/types/adapter.ts, add:
export type AdapterFileConfig = {
serverUrl?: string
defaultProjectDir?: string // ← NEW
telegram?: { ... }
feishu?: { ... }
}
- Step 4: Add UI field in AdapterSettings.tsx
Add a defaultProjectDir state + DirectoryPicker between the server URL and the Telegram section:
// Add import at top
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
// Add state
const [defaultProjectDir, setDefaultProjectDir] = useState('')
// In useEffect config sync, add:
setDefaultProjectDir(config.defaultProjectDir ?? '')
// In handleSave, add to patch:
if (defaultProjectDir) patch.defaultProjectDir = defaultProjectDir
// In JSX, after the Server URL Input and before the Telegram section:
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.adapters.defaultProject')}
</label>
<DirectoryPicker value={defaultProjectDir} onChange={setDefaultProjectDir} />
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.defaultProjectHint')}
</p>
</div>
- Step 5: Add i18n keys
In desktop/src/i18n/locales/en.ts, in the adapters section:
'settings.adapters.defaultProject': 'Default Project',
'settings.adapters.defaultProjectHint': 'Default working directory for new IM sessions. If empty, the bot will ask you to choose.',
In desktop/src/i18n/locales/zh.ts:
'settings.adapters.defaultProject': '默认项目',
'settings.adapters.defaultProjectHint': '新 IM 会话的默认工作目录。留空则由 Bot 询问选择。',
- Step 6: Update API validation whitelist
In src/server/api/adapters.ts, add 'defaultProjectDir' to ALLOWED_TOP_KEYS:
const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu'])
- Step 7: Run TypeScript check
Run: cd desktop && npx tsc --noEmit
Expected: No errors
- Step 8: Commit
git add adapters/common/config.ts src/server/services/adapterService.ts src/server/api/adapters.ts \
desktop/src/types/adapter.ts desktop/src/pages/AdapterSettings.tsx \
desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts
git commit -m "feat: add defaultProjectDir to adapter config and settings UI"
Task 5: Telegram Adapter — Session 管理 + /projects 命令
Files:
-
Modify:
adapters/telegram/index.ts -
Step 1: Add imports and initialization
At the top of adapters/telegram/index.ts, add imports and initialize new modules:
// Add these imports after existing ones
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
// After existing init section (after dedup initialization)
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
// Track chats waiting for project selection
const pendingProjectSelection = new Map<string, boolean>()
- Step 2: Replace setupMessageHandler with session-aware version
Replace the current setupMessageHandler function with:
async function ensureSession(chatId: string): Promise<boolean> {
// Already connected?
if (bridge.hasSession(chatId)) return true
// Has stored session? Reconnect.
const stored = sessionStore.get(chatId)
if (stored) {
bridge.connectSession(chatId, stored.sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
return true
}
// Need to create a new session — use default project or ask
const workDir = config.defaultProjectDir
if (workDir) {
return await createSessionForChat(chatId, workDir)
}
// No default — ask user to pick
await showProjectPicker(chatId)
return false // message not sent yet, waiting for project selection
}
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
const numericChatId = Number(chatId)
try {
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
return true
} catch (err) {
await bot.api.sendMessage(numericChatId,
`❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
return false
}
}
async function showProjectPicker(chatId: string): Promise<void> {
const numericChatId = Number(chatId)
try {
const projects = await httpClient.listRecentProjects()
if (projects.length === 0) {
await bot.api.sendMessage(numericChatId,
'没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在 Settings → IM 接入中配置默认项目。')
return
}
const lines = projects.slice(0, 10).map((p, i) =>
`${i + 1}. ${p.projectName}${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
)
pendingProjectSelection.set(chatId, true)
await bot.api.sendMessage(numericChatId,
`选择项目(回复编号):\n\n${lines.join('\n\n')}`)
} catch (err) {
await bot.api.sendMessage(numericChatId,
`❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
- Step 3: Extract handleServerMessage from inline closure
Extract the server message handler (currently the inline async (msg: ServerMessage) => { ... } in the old setupMessageHandler) into a standalone function. The body is the same switch(msg.type) block, just named:
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
const numericChatId = Number(chatId)
const buf = getBuffer(chatId)
switch (msg.type) {
// ... exact same cases as before (status, content_start, content_delta,
// thinking, tool_use_complete, tool_result, permission_request,
// message_complete, error)
// No changes to the switch body.
}
}
- Step 4: Update /new command
Replace the /new command handler:
bot.command('new', async (ctx) => {
const chatId = String(ctx.chat.id)
// Clean up current session state
bridge.resetSession(chatId)
sessionStore.delete(chatId)
placeholders.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
// Show project picker for next session
await showProjectPicker(chatId)
})
- Step 5: Add /projects command
bot.command('projects', async (ctx) => {
const chatId = String(ctx.chat.id)
await showProjectPicker(chatId)
})
- Step 6: Update message handler for project selection + normal messages
Replace the bot.on('message:text') handler:
bot.on('message:text', (ctx) => {
if (!ctx.from || !isAllowed(ctx.from.id)) return
if (!dedup.tryRecord(String(ctx.message.message_id))) return
const chatId = String(ctx.chat.id)
const text = ctx.message.text
enqueue(chatId, async () => {
// Check if user is responding to project selection
if (pendingProjectSelection.has(chatId)) {
const num = parseInt(text, 10)
if (num >= 1) {
try {
const projects = await httpClient.listRecentProjects()
const selected = projects[num - 1]
if (selected) {
pendingProjectSelection.delete(chatId)
await createSessionForChat(chatId, selected.realPath)
await bot.api.sendMessage(Number(chatId),
`✅ 已选择 ${selected.projectName}。现在可以开始对话了。`)
return
}
} catch { /* fall through to normal handling */ }
}
// Invalid selection — tell user
await bot.api.sendMessage(Number(chatId), '请输入有效的编号。')
return
}
// Normal message flow
const ready = await ensureSession(chatId)
if (ready) {
bridge.sendUserMessage(chatId, text)
}
// If not ready, ensureSession already showed the project picker
})
})
- Step 7: Update /start command help text
bot.command('start', (ctx) => {
ctx.reply(
'👋 Claude Code Bot 已就绪。\n\n' +
'命令:\n' +
'/projects — 选择/切换项目\n' +
'/new — 新建会话\n' +
'/stop — 停止生成'
)
})
- Step 8: Update SIGINT handler — add sessionStore
No change needed, sessionStore is sync-write so data is already persisted.
- Step 9: Run all adapter tests
Run: cd adapters && bun test
Expected: All pass (telegram mock tests may need updating if they reference getOrCreateSession — update mocks to use connectSession)
- Step 10: Commit
git add adapters/telegram/index.ts
git commit -m "feat(telegram): session persistence, /projects command, project selection"
Task 6: Feishu Adapter — Session 管理 + /projects 命令
Files:
-
Modify:
adapters/feishu/index.ts -
Step 1: Add imports and initialization
Same pattern as Telegram — add at the top:
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
// After existing init
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
const pendingProjectSelection = new Map<string, boolean>()
- Step 2: Add ensureSession, createSessionForChat, showProjectPicker
Same functions as Telegram, but using sendText(chatId, text) instead of bot.api.sendMessage(...):
async function ensureSession(chatId: string): Promise<boolean> {
if (bridge.hasSession(chatId)) return true
const stored = sessionStore.get(chatId)
if (stored) {
bridge.connectSession(chatId, stored.sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
return true
}
const workDir = config.defaultProjectDir
if (workDir) {
return await createSessionForChat(chatId, workDir)
}
await showProjectPicker(chatId)
return false
}
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
try {
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
return true
} catch (err) {
await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
return false
}
}
async function showProjectPicker(chatId: string): Promise<void> {
try {
const projects = await httpClient.listRecentProjects()
if (projects.length === 0) {
await sendText(chatId,
'没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在设置中配置默认项目。')
return
}
const lines = projects.slice(0, 10).map((p, i) =>
`${i + 1}. **${p.projectName}**${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
)
pendingProjectSelection.set(chatId, true)
await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}`)
} catch (err) {
await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
- Step 3: Extract handleServerMessage
Same as Telegram — extract the switch(msg.type) body from the inline closure in old setupMessageHandler into a standalone handleServerMessage(chatId, msg) function.
- Step 4: Update command handling in handleMessage
In the handleMessage function, update the command section (around line 364):
// Handle commands
if (text === '/new' || text === '新会话') {
bridge.resetSession(chatId)
sessionStore.delete(chatId)
chatStates.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
await showProjectPicker(chatId)
return
}
if (text === '/stop' || text === '停止') {
bridge.sendStopGeneration(chatId)
await sendText(chatId, '⏹ 已发送停止信号。')
return
}
if (text === '/projects' || text === '项目列表') {
await showProjectPicker(chatId)
return
}
// Check if user is responding to project selection
if (pendingProjectSelection.has(chatId)) {
const num = parseInt(text, 10)
if (num >= 1) {
try {
const projects = await httpClient.listRecentProjects()
const selected = projects[num - 1]
if (selected) {
pendingProjectSelection.delete(chatId)
await createSessionForChat(chatId, selected.realPath)
await sendText(chatId, `✅ 已选择 **${selected.projectName}**。现在可以开始对话了。`)
return
}
} catch { /* fall through */ }
}
await sendText(chatId, '请输入有效的编号。')
return
}
// Normal message flow
enqueue(chatId, async () => {
const ready = await ensureSession(chatId)
if (ready) {
bridge.sendUserMessage(chatId, text!)
}
})
- Step 5: Run all adapter tests
Run: cd adapters && bun test
Expected: All pass
- Step 6: Commit
git add adapters/feishu/index.ts
git commit -m "feat(feishu): session persistence, /projects command, project selection"
Task 7: Update existing tests for API changes
Files:
-
Modify:
adapters/telegram/__tests__/telegram.test.ts -
Modify:
adapters/feishu/__tests__/feishu.test.ts -
Modify:
adapters/common/__tests__/ws-bridge.test.ts -
Step 1: Update ws-bridge tests
Replace any getOrCreateSession calls with connectSession:
// Find: bridge.getOrCreateSession('chat-1')
// Replace: bridge.connectSession('chat-1', 'test-session-id')
- Step 2: Update telegram tests
Update the mocked bridge to use connectSession instead of getOrCreateSession. Update any mock setup that references the old API.
- Step 3: Update feishu tests
Same as telegram test updates.
- Step 4: Run all tests
Run: cd adapters && bun test
Expected: All pass, 0 fail
- Step 5: Commit
git add adapters/telegram/__tests__/telegram.test.ts adapters/feishu/__tests__/feishu.test.ts \
adapters/common/__tests__/ws-bridge.test.ts
git commit -m "test: update adapter tests for connectSession API"
Task 8: Final TypeScript check + verify Desktop build
Files: None new — verification only.
- Step 1: TypeScript check for desktop
Run: cd desktop && npx tsc --noEmit
Expected: No errors
- Step 2: Run all adapter tests
Run: cd adapters && bun test
Expected: All pass
- Step 3: Verify adapters.json is properly written
Run: cat ~/.claude/adapters.json (if exists)
Verify defaultProjectDir field can be read.
- Step 4: Final commit (if any fixups needed)
git add -A
git commit -m "chore: fixup after session persistence integration"