mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
IM adapters could keep in-process bridge state after desktop session deletion removed the persisted mapping or after the server no longer had the mapped session. The restore path now validates the stored mapping against bridge memory and the server session before reusing it, then clears stale transient state before creating a replacement session. Constraint: Issue #574 is intermittent and tied to adapter-created sessions; live reporter Telegram credentials are not available locally. Rejected: Keep separate recovery checks in each IM adapter | duplicates the same stale-state logic across Telegram, Feishu, WeChat, and DingTalk. Confidence: high Scope-risk: moderate Directive: Do not use bridge.hasSession() as the sole restore check; verify adapter-sessions state and server session existence first. Tested: bun run check:adapters (364 pass, 0 fail) Tested: cd adapters && bunx tsc --noEmit Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/websocket-handler.test.ts --timeout 30000 (110 pass, 0 fail) Tested: git diff --check Not-tested: Live Telegram bot/provider reproduction on the reporter machine
225 lines
6.8 KiB
TypeScript
225 lines
6.8 KiB
TypeScript
import * as fs from 'node:fs'
|
|
import * as os from 'node:os'
|
|
import * as path from 'node:path'
|
|
|
|
export type RecentProject = {
|
|
projectPath: string
|
|
realPath: string
|
|
projectName: string
|
|
isGit: boolean
|
|
repoName: string | null
|
|
branch: string | null
|
|
modifiedAt: string
|
|
sessionCount: number
|
|
}
|
|
|
|
export type GitInfo = {
|
|
branch: string | null
|
|
repoName: string | null
|
|
workDir: string
|
|
changedFiles: number
|
|
}
|
|
|
|
export type SessionTask = {
|
|
id: string
|
|
subject: string
|
|
status: 'pending' | 'in_progress' | 'completed'
|
|
}
|
|
|
|
export class AdapterHttpClient {
|
|
readonly httpBaseUrl: string
|
|
private readonly allowedProjectRoots: string[]
|
|
/** Default timeout for HTTP requests (30 seconds) */
|
|
private static readonly DEFAULT_TIMEOUT_MS = 30_000
|
|
|
|
constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) {
|
|
this.httpBaseUrl = wsUrl
|
|
.replace(/^ws:/, 'http:')
|
|
.replace(/^wss:/, 'https:')
|
|
.replace(/\/$/, '')
|
|
this.allowedProjectRoots = (options?.allowedProjectRoots ?? [])
|
|
.map(resolveExistingProjectPath)
|
|
.filter((value): value is string => Boolean(value))
|
|
}
|
|
|
|
/** Create an AbortController with timeout */
|
|
private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): {
|
|
controller: AbortController
|
|
timer: ReturnType<typeof setTimeout>
|
|
} {
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
return { controller, timer }
|
|
}
|
|
|
|
async createSession(workDir: string): Promise<string> {
|
|
const { controller, timer } = this.createTimeoutController()
|
|
try {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir }),
|
|
signal: controller.signal,
|
|
})
|
|
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
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async sessionExists(sessionId: string): Promise<boolean> {
|
|
const { controller, timer } = this.createTimeoutController()
|
|
try {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
|
signal: controller.signal,
|
|
})
|
|
if (res.status === 404) return false
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
throw new Error(`Failed to check session: ${(err as any).message}`)
|
|
}
|
|
return true
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async listRecentProjects(): Promise<RecentProject[]> {
|
|
const { controller, timer } = this.createTimeoutController()
|
|
try {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, {
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to list projects: ${res.statusText}`)
|
|
}
|
|
const data = (await res.json()) as { projects: RecentProject[] }
|
|
return data.projects
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Match a project by index (1-based) or fuzzy name from recent projects.
|
|
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
|
|
*/
|
|
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
|
|
const directPath = resolveExistingProjectPath(query)
|
|
if (directPath) {
|
|
if (!isPathWithinAllowedRoots(directPath, this.allowedProjectRoots)) {
|
|
return {}
|
|
}
|
|
|
|
return {
|
|
project: {
|
|
projectPath: directPath,
|
|
realPath: directPath,
|
|
projectName: path.basename(directPath) || directPath,
|
|
isGit: fs.existsSync(path.join(directPath, '.git')),
|
|
repoName: null,
|
|
branch: null,
|
|
modifiedAt: new Date().toISOString(),
|
|
sessionCount: 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
const projects = await this.listRecentProjects()
|
|
|
|
// Try as 1-based index
|
|
const num = parseInt(query, 10)
|
|
if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
|
|
return { project: projects[num - 1] }
|
|
}
|
|
|
|
const q = query.toLowerCase()
|
|
|
|
// Exact project name match
|
|
const exact = projects.find(p => p.projectName.toLowerCase() === q)
|
|
if (exact) return { project: exact }
|
|
|
|
// Fuzzy: name or path contains query
|
|
const matches = projects.filter(p =>
|
|
p.projectName.toLowerCase().includes(q) ||
|
|
p.realPath.toLowerCase().includes(q)
|
|
)
|
|
if (matches.length === 1) return { project: matches[0] }
|
|
if (matches.length > 1) return { ambiguous: matches }
|
|
|
|
return {}
|
|
}
|
|
|
|
async getGitInfo(sessionId: string): Promise<GitInfo> {
|
|
const { controller, timer } = this.createTimeoutController()
|
|
try {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
throw new Error(`Failed to load git info: ${(err as any).message}`)
|
|
}
|
|
return (await res.json()) as GitInfo
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
|
|
const { controller, timer } = this.createTimeoutController()
|
|
try {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.ok) {
|
|
if (res.status === 404) return []
|
|
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
throw new Error(`Failed to load tasks: ${(err as any).message}`)
|
|
}
|
|
const data = (await res.json()) as { tasks?: SessionTask[] }
|
|
return Array.isArray(data.tasks) ? data.tasks : []
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
}
|
|
|
|
function isPathWithinAllowedRoots(target: string, roots: string[]): boolean {
|
|
if (roots.length === 0) return false
|
|
|
|
for (const root of roots) {
|
|
const relative = path.relative(root, target)
|
|
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function resolveExistingProjectPath(query: string): string | null {
|
|
const trimmed = query.trim()
|
|
if (!trimmed) return null
|
|
|
|
const expanded = trimmed === '~'
|
|
? os.homedir()
|
|
: trimmed.startsWith('~/')
|
|
? path.join(os.homedir(), trimmed.slice(2))
|
|
: trimmed
|
|
|
|
if (!path.isAbsolute(expanded)) return null
|
|
|
|
try {
|
|
const realPath = fs.realpathSync(expanded)
|
|
return fs.statSync(realPath).isDirectory() ? realPath : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|