mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(telegram): complete secure agent control loop #1130
This commit is contained in:
@@ -25,20 +25,29 @@ describe('AdapterHttpClient', () => {
|
||||
|
||||
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 rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
|
||||
try {
|
||||
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
|
||||
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 sessionId = await client.createSession(rootDir)
|
||||
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')
|
||||
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).toEqual({
|
||||
workDir: fs.realpathSync(rootDir),
|
||||
permissionMode: 'default',
|
||||
})
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('authenticates requests with the desktop local access token', async () => {
|
||||
@@ -54,18 +63,51 @@ describe('AdapterHttpClient', () => {
|
||||
})
|
||||
|
||||
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 rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
|
||||
const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'))
|
||||
try {
|
||||
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
|
||||
const mockProjects = [
|
||||
{ projectName: 'my-app', realPath: projectDir, 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')
|
||||
const projects = await client.listRecentProjects()
|
||||
expect(projects).toHaveLength(1)
|
||||
expect(projects[0].projectName).toBe('my-app')
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('filters recent projects before index, name, and fuzzy matching', async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
|
||||
const allowedDir = fs.mkdtempSync(path.join(rootDir, 'allowed-'))
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'))
|
||||
try {
|
||||
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(Response.json({
|
||||
projects: [
|
||||
{ projectName: 'secret', realPath: outsideDir, sessionCount: 2 },
|
||||
{ projectName: 'allowed', realPath: allowedDir, sessionCount: 1 },
|
||||
],
|
||||
}))
|
||||
) as any
|
||||
|
||||
await expect(client.matchProject('1')).resolves.toMatchObject({
|
||||
project: { projectName: 'allowed' },
|
||||
})
|
||||
await expect(client.matchProject('secret')).resolves.toEqual({})
|
||||
await expect(client.matchProject(outsideDir)).resolves.toEqual({})
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true })
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => {
|
||||
@@ -132,6 +174,29 @@ describe('AdapterHttpClient', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sessionExists rejects sessions outside the root or using bypassPermissions', async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'))
|
||||
try {
|
||||
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
|
||||
globalThis.fetch = mock((url: string) => {
|
||||
const unsafe = url.endsWith('/outside')
|
||||
return Promise.resolve(Response.json({
|
||||
status: {
|
||||
workDir: unsafe ? outsideDir : rootDir,
|
||||
permissionMode: unsafe ? 'default' : 'bypassPermissions',
|
||||
},
|
||||
}))
|
||||
}) as any
|
||||
|
||||
await expect(client.sessionExists('outside')).resolves.toBe(false)
|
||||
await expect(client.sessionExists('bypass')).resolves.toBe(false)
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true })
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('getGitInfo calls GET /api/sessions/:id/git-info', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -172,33 +237,53 @@ describe('AdapterHttpClient', () => {
|
||||
})
|
||||
|
||||
it('listSessions calls GET /api/sessions with project and pagination query', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-1',
|
||||
title: 'Fix Telegram menu',
|
||||
createdAt: '2026-06-09T00:00:00.000Z',
|
||||
modifiedAt: '2026-06-09T01:00:00.000Z',
|
||||
messageCount: 3,
|
||||
projectPath: '-repo',
|
||||
projectRoot: '/repo',
|
||||
workDir: '/repo',
|
||||
workDirExists: true,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
) as any
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
|
||||
try {
|
||||
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-1',
|
||||
title: 'Fix Telegram menu',
|
||||
createdAt: '2026-06-09T00:00:00.000Z',
|
||||
modifiedAt: '2026-06-09T01:00:00.000Z',
|
||||
messageCount: 3,
|
||||
projectPath: rootDir,
|
||||
projectRoot: rootDir,
|
||||
workDir: rootDir,
|
||||
workDirExists: true,
|
||||
permissionMode: 'default',
|
||||
},
|
||||
{
|
||||
id: 'unsafe-session',
|
||||
title: 'Unsafe',
|
||||
createdAt: '2026-06-09T00:00:00.000Z',
|
||||
modifiedAt: '2026-06-09T01:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: rootDir,
|
||||
projectRoot: rootDir,
|
||||
workDir: rootDir,
|
||||
workDirExists: true,
|
||||
permissionMode: 'bypassPermissions',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
) as any
|
||||
|
||||
const result = await client.listSessions({ project: '/repo', limit: 10, offset: 5 })
|
||||
const result = await client.listSessions({ project: rootDir, limit: 10, offset: 5 })
|
||||
|
||||
expect(result.sessions[0]?.id).toBe('session-1')
|
||||
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
||||
'http://127.0.0.1:3456/api/sessions?project=%2Frepo&limit=10&offset=5',
|
||||
)
|
||||
expect(result.sessions.map((session) => session.id)).toEqual(['session-1'])
|
||||
expect(result.total).toBe(1)
|
||||
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
||||
`http://127.0.0.1:3456/api/sessions?project=${encodeURIComponent(rootDir)}&limit=10&offset=5`,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('lists and activates providers through the server provider API', async () => {
|
||||
@@ -264,28 +349,34 @@ describe('AdapterHttpClient', () => {
|
||||
})
|
||||
|
||||
it('lists skills for a cwd through the server skills API', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({
|
||||
skills: [
|
||||
{
|
||||
name: 'reviewer',
|
||||
description: 'Review code',
|
||||
source: 'user',
|
||||
userInvocable: true,
|
||||
contentLength: 120,
|
||||
hasDirectory: true,
|
||||
},
|
||||
],
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
) as any
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
|
||||
try {
|
||||
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({
|
||||
skills: [
|
||||
{
|
||||
name: 'reviewer',
|
||||
description: 'Review code',
|
||||
source: 'user',
|
||||
userInvocable: true,
|
||||
contentLength: 120,
|
||||
hasDirectory: true,
|
||||
},
|
||||
],
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
) as any
|
||||
|
||||
const result = await client.listSkills('/repo')
|
||||
const result = await client.listSkills(rootDir)
|
||||
|
||||
expect(result.skills[0]?.name).toBe('reviewer')
|
||||
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
||||
'http://127.0.0.1:3456/api/skills?cwd=%2Frepo',
|
||||
)
|
||||
expect(result.skills[0]?.name).toBe('reviewer')
|
||||
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
||||
`http://127.0.0.1:3456/api/skills?cwd=${encodeURIComponent(fs.realpathSync(rootDir))}`,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { IMAGE_MAX_BYTES } from '../attachment-limits.js'
|
||||
import { loadSafeOutboundImage, sendSafeOutboundImage } from '../outbound-image.js'
|
||||
|
||||
const temporaryRoots: string[] = []
|
||||
|
||||
function makeTempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
temporaryRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('loadSafeOutboundImage', () => {
|
||||
it('requires an active session work directory', async () => {
|
||||
const result = await loadSafeOutboundImage({
|
||||
id: 'missing-session',
|
||||
source: { kind: 'base64', data: 'cG5n', mime: 'image/png' },
|
||||
}, null)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing session work directory',
|
||||
})
|
||||
})
|
||||
|
||||
it('loads a bounded image inside the active session work directory', async () => {
|
||||
const root = makeTempRoot('outbound-root-')
|
||||
const imagePath = path.join(root, 'result.png')
|
||||
fs.writeFileSync(imagePath, Buffer.from('png'))
|
||||
|
||||
const result = await loadSafeOutboundImage({
|
||||
id: 'inside',
|
||||
source: { kind: 'path', path: imagePath, mime: 'image/png' },
|
||||
}, root)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.buffer.toString()).toBe('png')
|
||||
})
|
||||
|
||||
it('rejects paths outside the session root, including symlink escapes', async () => {
|
||||
const root = makeTempRoot('outbound-root-')
|
||||
const outside = makeTempRoot('outbound-outside-')
|
||||
const secretPath = path.join(outside, 'secret.png')
|
||||
const symlinkPath = path.join(root, 'escape.png')
|
||||
fs.writeFileSync(secretPath, Buffer.from('secret'))
|
||||
fs.symlinkSync(secretPath, symlinkPath)
|
||||
|
||||
const direct = await loadSafeOutboundImage({
|
||||
id: 'outside',
|
||||
source: { kind: 'path', path: secretPath, mime: 'image/png' },
|
||||
}, root)
|
||||
const symlink = await loadSafeOutboundImage({
|
||||
id: 'symlink',
|
||||
source: { kind: 'path', path: symlinkPath, mime: 'image/png' },
|
||||
}, root)
|
||||
|
||||
expect(direct).toEqual({
|
||||
ok: false,
|
||||
reason: 'image path is outside the active session work directory',
|
||||
})
|
||||
expect(symlink).toEqual(direct)
|
||||
})
|
||||
|
||||
it('rejects missing paths and directories', async () => {
|
||||
const root = makeTempRoot('outbound-root-')
|
||||
const directory = path.join(root, 'directory.png')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
const missing = await loadSafeOutboundImage({
|
||||
id: 'missing',
|
||||
source: { kind: 'path', path: path.join(root, 'missing.png'), mime: 'image/png' },
|
||||
}, root)
|
||||
const notFile = await loadSafeOutboundImage({
|
||||
id: 'directory',
|
||||
source: { kind: 'path', path: directory, mime: 'image/png' },
|
||||
}, root)
|
||||
|
||||
expect(missing).toEqual({ ok: false, reason: 'image path does not exist' })
|
||||
expect(notFile).toEqual({ ok: false, reason: 'image path is not a regular file' })
|
||||
})
|
||||
|
||||
it('never fetches remote URLs from Agent-authored markdown', async () => {
|
||||
const root = makeTempRoot('outbound-root-')
|
||||
const result = await loadSafeOutboundImage({
|
||||
id: 'url',
|
||||
source: { kind: 'url', url: 'http://127.0.0.1:3456/private' },
|
||||
}, root)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'remote image URLs are not fetched from Agent output',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects oversized local files before buffering them', async () => {
|
||||
const root = makeTempRoot('outbound-root-')
|
||||
const imagePath = path.join(root, 'large.png')
|
||||
fs.writeFileSync(imagePath, Buffer.alloc(IMAGE_MAX_BYTES + 1))
|
||||
|
||||
const result = await loadSafeOutboundImage({
|
||||
id: 'large',
|
||||
source: { kind: 'path', path: imagePath, mime: 'image/png' },
|
||||
}, root)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('图片过大')
|
||||
})
|
||||
|
||||
it('preflights base64 size and MIME before decoding', async () => {
|
||||
const root = makeTempRoot('outbound-root-')
|
||||
const allowed = await loadSafeOutboundImage({
|
||||
id: 'base64',
|
||||
source: { kind: 'base64', data: 'cG5n', mime: 'image/png' },
|
||||
}, root)
|
||||
const unsupported = await loadSafeOutboundImage({
|
||||
id: 'svg',
|
||||
source: { kind: 'base64', data: 'AAAA', mime: 'image/svg+xml' },
|
||||
}, root)
|
||||
const oversized = await loadSafeOutboundImage({
|
||||
id: 'large-base64',
|
||||
source: {
|
||||
kind: 'base64',
|
||||
data: 'A'.repeat(Math.ceil((IMAGE_MAX_BYTES + 1) * 4 / 3)),
|
||||
mime: 'image/png',
|
||||
},
|
||||
}, root)
|
||||
|
||||
expect(allowed.ok).toBe(true)
|
||||
if (allowed.ok) expect(allowed.buffer.toString()).toBe('png')
|
||||
expect(unsupported.ok).toBe(false)
|
||||
expect(oversized.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('only calls the platform sender after the image passes validation', async () => {
|
||||
const root = makeTempRoot('outbound-root-')
|
||||
const imagePath = path.join(root, 'result.png')
|
||||
fs.writeFileSync(imagePath, Buffer.from('png'))
|
||||
const sent: string[] = []
|
||||
|
||||
const allowed = await sendSafeOutboundImage({
|
||||
id: 'inside',
|
||||
source: { kind: 'path', path: imagePath, mime: 'image/png' },
|
||||
}, root, async (buffer, mime) => {
|
||||
sent.push(`${buffer.toString()}:${mime}`)
|
||||
})
|
||||
const blocked = await sendSafeOutboundImage({
|
||||
id: 'url',
|
||||
source: { kind: 'url', url: 'http://127.0.0.1/private' },
|
||||
}, root, async () => {
|
||||
sent.push('unsafe')
|
||||
})
|
||||
|
||||
expect(allowed.ok).toBe(true)
|
||||
expect(blocked.ok).toBe(false)
|
||||
expect(sent).toEqual(['png:image/png'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { IMAGE_MAX_BYTES, checkAttachmentLimit } from './attachment-limits.js'
|
||||
import type { PendingUpload } from './attachment-types.js'
|
||||
|
||||
export type SafeOutboundImage =
|
||||
| { ok: true; buffer: Buffer; mime: string }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
export async function loadSafeOutboundImage(
|
||||
pending: PendingUpload,
|
||||
sessionWorkDir: string | null | undefined,
|
||||
): Promise<SafeOutboundImage> {
|
||||
if (!sessionWorkDir) {
|
||||
return { ok: false, reason: 'missing session work directory' }
|
||||
}
|
||||
|
||||
if (pending.source.kind === 'url') {
|
||||
return { ok: false, reason: 'remote image URLs are not fetched from Agent output' }
|
||||
}
|
||||
|
||||
if (pending.source.kind === 'base64') {
|
||||
const mime = pending.source.mime
|
||||
const estimatedBytes = Math.ceil(pending.source.data.length * 3 / 4)
|
||||
const preflight = checkAttachmentLimit('image', estimatedBytes, mime)
|
||||
if (!preflight.ok) return { ok: false, reason: preflight.hint }
|
||||
|
||||
const buffer = Buffer.from(pending.source.data, 'base64')
|
||||
const exact = checkAttachmentLimit('image', buffer.length, mime)
|
||||
return exact.ok
|
||||
? { ok: true, buffer, mime }
|
||||
: { ok: false, reason: exact.hint }
|
||||
}
|
||||
|
||||
let canonicalRoot: string
|
||||
let canonicalFile: string
|
||||
try {
|
||||
canonicalRoot = await fs.realpath(sessionWorkDir)
|
||||
canonicalFile = await fs.realpath(pending.source.path)
|
||||
} catch {
|
||||
return { ok: false, reason: 'image path does not exist' }
|
||||
}
|
||||
|
||||
const relative = path.relative(canonicalRoot, canonicalFile)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return { ok: false, reason: 'image path is outside the active session work directory' }
|
||||
}
|
||||
|
||||
const mime = pending.source.mime ?? 'image/png'
|
||||
const file = await fs.open(canonicalFile, 'r')
|
||||
try {
|
||||
const stat = await file.stat()
|
||||
if (!stat.isFile()) {
|
||||
return { ok: false, reason: 'image path is not a regular file' }
|
||||
}
|
||||
const preflight = checkAttachmentLimit('image', stat.size, mime)
|
||||
if (!preflight.ok) return { ok: false, reason: preflight.hint }
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
const stream = file.createReadStream({ autoClose: false })
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
total += buffer.length
|
||||
if (total > IMAGE_MAX_BYTES) {
|
||||
stream.destroy()
|
||||
return { ok: false, reason: 'image exceeded the 10 MB limit while reading' }
|
||||
}
|
||||
chunks.push(buffer)
|
||||
}
|
||||
|
||||
return { ok: true, buffer: Buffer.concat(chunks, total), mime }
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSafeOutboundImage(
|
||||
pending: PendingUpload,
|
||||
sessionWorkDir: string | null | undefined,
|
||||
send: (buffer: Buffer, mime: string) => Promise<unknown>,
|
||||
): Promise<SafeOutboundImage> {
|
||||
const loaded = await loadSafeOutboundImage(pending, sessionWorkDir)
|
||||
if (!loaded.ok) return loaded
|
||||
await send(loaded.buffer, loaded.mime)
|
||||
return loaded
|
||||
}
|
||||
@@ -112,12 +112,20 @@ export class AdapterHttpClient {
|
||||
}
|
||||
|
||||
async createSession(workDir: string): Promise<string> {
|
||||
const allowedWorkDir = this.resolveAllowedProjectPath(workDir)
|
||||
if (!allowedWorkDir) {
|
||||
throw new Error('Failed to create session: workDir is outside the configured project roots')
|
||||
}
|
||||
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await this.request('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir }),
|
||||
body: JSON.stringify({
|
||||
workDir: allowedWorkDir,
|
||||
permissionMode: 'default',
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -142,7 +150,13 @@ export class AdapterHttpClient {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to check session: ${(err as any).message}`)
|
||||
}
|
||||
return true
|
||||
const data = (await res.json()) as {
|
||||
status?: {
|
||||
workDir?: string
|
||||
permissionMode?: string
|
||||
}
|
||||
}
|
||||
return this.isSafeRemoteSession(data.status)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
@@ -158,7 +172,9 @@ export class AdapterHttpClient {
|
||||
throw new Error(`Failed to list projects: ${res.statusText}`)
|
||||
}
|
||||
const data = (await res.json()) as { projects: RecentProject[] }
|
||||
return data.projects
|
||||
return data.projects.filter((project) =>
|
||||
this.resolveAllowedProjectPath(project.realPath || project.projectPath),
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
@@ -169,11 +185,10 @@ export class AdapterHttpClient {
|
||||
* 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 {}
|
||||
}
|
||||
const resolvedDirectPath = resolveExistingProjectPath(query)
|
||||
if (resolvedDirectPath) {
|
||||
const directPath = this.resolveAllowedProjectPath(resolvedDirectPath)
|
||||
if (!directPath) return {}
|
||||
|
||||
return {
|
||||
project: {
|
||||
@@ -253,6 +268,10 @@ export class AdapterHttpClient {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ sessions: SessionListItem[]; total: number }> {
|
||||
if (options?.project && !this.resolveAllowedProjectPath(options.project)) {
|
||||
return { sessions: [], total: 0 }
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (options?.project) params.set('project', options.project)
|
||||
if (options?.limit !== undefined) params.set('limit', String(options.limit))
|
||||
@@ -268,7 +287,12 @@ export class AdapterHttpClient {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to list sessions: ${(err as any).message}`)
|
||||
}
|
||||
return (await res.json()) as { sessions: SessionListItem[]; total: number }
|
||||
const data = (await res.json()) as { sessions: SessionListItem[]; total: number }
|
||||
const sessions = data.sessions.filter((session) => this.isSafeRemoteSession({
|
||||
workDir: session.workDir,
|
||||
permissionMode: session.permissionMode,
|
||||
}))
|
||||
return { sessions, total: sessions.length }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
@@ -335,7 +359,12 @@ export class AdapterHttpClient {
|
||||
}
|
||||
|
||||
async listSkills(cwd: string): Promise<{ skills: SkillSummary[] }> {
|
||||
const params = new URLSearchParams({ cwd })
|
||||
const allowedCwd = this.resolveAllowedProjectPath(cwd)
|
||||
if (!allowedCwd) {
|
||||
throw new Error('Failed to list skills: cwd is outside the configured project roots')
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ cwd: allowedCwd })
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await this.request(`/api/skills?${params.toString()}`, {
|
||||
@@ -384,6 +413,24 @@ export class AdapterHttpClient {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAllowedProjectPath(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
const resolved = resolveExistingProjectPath(value)
|
||||
if (!resolved || !isPathWithinAllowedRoots(resolved, this.allowedProjectRoots)) {
|
||||
return null
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private isSafeRemoteSession(
|
||||
status: { workDir?: string | null; permissionMode?: string } | undefined,
|
||||
): boolean {
|
||||
if (!status?.workDir || status.permissionMode === 'bypassPermissions') {
|
||||
return false
|
||||
}
|
||||
return Boolean(this.resolveAllowedProjectPath(status.workDir))
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithinAllowedRoots(target: string, roots: string[]): boolean {
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
registerAuthorizedTelegramCommand,
|
||||
registerTelegramExtendedCommands,
|
||||
renderSelectionView,
|
||||
resolveTelegramPermissionCallback,
|
||||
sessionToSelectionItem,
|
||||
shouldProcessTelegramMessage,
|
||||
skillToSelectionItem,
|
||||
telegramMessageDedupKey,
|
||||
tryHandleTelegramSelectionCallback,
|
||||
} from '../commands.js'
|
||||
|
||||
@@ -47,6 +50,7 @@ function createCommandContext(options?: {
|
||||
|
||||
function createController(overrides?: Record<string, unknown>) {
|
||||
const sent: Array<{ chatId: number; text: string; options?: unknown }> = []
|
||||
const sentUserMessages: Array<{ chatId: string; content: string }> = []
|
||||
const runtimeModels: string[] = []
|
||||
const bridgeEvents: string[] = []
|
||||
const deps = {
|
||||
@@ -137,6 +141,10 @@ function createController(overrides?: Record<string, unknown>) {
|
||||
}),
|
||||
onBridgeServerMessage: mock((chatId: string) => bridgeEvents.push(`listen:${chatId}`)),
|
||||
waitForBridgeOpen: mock(async () => true),
|
||||
sendUserMessage: mock((chatId: string, content: string) => {
|
||||
sentUserMessages.push({ chatId, content })
|
||||
return true
|
||||
}),
|
||||
setRuntimeModel: mock((_chatId: string, modelId: string) => {
|
||||
runtimeModels.push(modelId)
|
||||
}),
|
||||
@@ -147,6 +155,7 @@ function createController(overrides?: Record<string, unknown>) {
|
||||
controller: createTelegramCommandController(deps),
|
||||
deps,
|
||||
sent,
|
||||
sentUserMessages,
|
||||
runtimeModels,
|
||||
bridgeEvents,
|
||||
}
|
||||
@@ -264,6 +273,66 @@ describe('Telegram command controller helpers', () => {
|
||||
expect(denied.replies[0]).toContain('未授权')
|
||||
})
|
||||
|
||||
it('only resolves pending permission callbacks from authorized users', () => {
|
||||
const sendPermissionResponse = mock(() => true)
|
||||
const pendingRequestIds = new Set(['req-1'])
|
||||
const base = {
|
||||
chatId: '42',
|
||||
decision: { requestId: 'req-1', allowed: true },
|
||||
pendingRequestIds,
|
||||
sendPermissionResponse,
|
||||
}
|
||||
|
||||
expect(resolveTelegramPermissionCallback({
|
||||
...base,
|
||||
userId: 999,
|
||||
isAllowedUser: () => false,
|
||||
})).toBe('unauthorized')
|
||||
expect(sendPermissionResponse).not.toHaveBeenCalled()
|
||||
|
||||
expect(resolveTelegramPermissionCallback({
|
||||
...base,
|
||||
userId: 7,
|
||||
decision: { requestId: 'expired', allowed: true },
|
||||
isAllowedUser: () => true,
|
||||
})).toBe('not_pending')
|
||||
expect(sendPermissionResponse).not.toHaveBeenCalled()
|
||||
|
||||
const disconnectedPending = new Set(['req-2'])
|
||||
expect(resolveTelegramPermissionCallback({
|
||||
...base,
|
||||
userId: 7,
|
||||
decision: { requestId: 'req-2', allowed: false },
|
||||
pendingRequestIds: disconnectedPending,
|
||||
isAllowedUser: () => true,
|
||||
sendPermissionResponse: () => false,
|
||||
})).toBe('send_failed')
|
||||
expect(disconnectedPending.has('req-2')).toBe(true)
|
||||
|
||||
expect(resolveTelegramPermissionCallback({
|
||||
...base,
|
||||
userId: 7,
|
||||
isAllowedUser: () => true,
|
||||
})).toBe('sent')
|
||||
expect(sendPermissionResponse).toHaveBeenCalledWith('42', 'req-1', true, undefined)
|
||||
expect(pendingRequestIds.has('req-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('scopes duplicate message keys to the Telegram chat', () => {
|
||||
expect(telegramMessageDedupKey('42', 7)).toBe('telegram:42:7')
|
||||
expect(telegramMessageDedupKey('43', 7)).not.toBe(telegramMessageDedupKey('42', 7))
|
||||
const keys: string[] = []
|
||||
const dedup = {
|
||||
tryRecord: (key: string) => {
|
||||
keys.push(key)
|
||||
return true
|
||||
},
|
||||
}
|
||||
expect(shouldProcessTelegramMessage(dedup, '42', undefined)).toBe(false)
|
||||
expect(shouldProcessTelegramMessage(dedup, '42', 7)).toBe(true)
|
||||
expect(keys).toEqual(['telegram:42:7'])
|
||||
})
|
||||
|
||||
it('syncs official provider command and rejects unauthorized private chats', async () => {
|
||||
const { controller, deps, runtimeModels } = createController()
|
||||
const allowed = createCommandContext({ match: 'claude' })
|
||||
@@ -421,8 +490,8 @@ describe('Telegram command controller helpers', () => {
|
||||
expect(callback.edits[0]).toContain('已切换模型')
|
||||
})
|
||||
|
||||
it('lists invocable skills and shows selected skill details', async () => {
|
||||
const { controller, deps, sent } = createController()
|
||||
it('lists invocable skills and sends the selected skill into the active agent session', async () => {
|
||||
const { controller, deps, sent, sentUserMessages } = createController()
|
||||
await controller.handleSkillsCommand(createCommandContext().ctx)
|
||||
|
||||
expect(deps.httpClient.listSkills).toHaveBeenCalledWith('/work/repo')
|
||||
@@ -434,7 +503,43 @@ describe('Telegram command controller helpers', () => {
|
||||
action: 'pick',
|
||||
index: 0,
|
||||
})
|
||||
expect(callback.edits[0]).toContain('Skill:Skill A')
|
||||
expect(deps.ensureExistingSession).toHaveBeenCalledWith('42')
|
||||
expect(sentUserMessages).toEqual([{ chatId: '42', content: '/skill-a' }])
|
||||
expect(callback.edits[0]).toContain('已调用 Skill:Skill A')
|
||||
})
|
||||
|
||||
it('does not claim a selected skill ran when the agent session is unavailable', async () => {
|
||||
const unavailable = createController({
|
||||
ensureExistingSession: mock(async () => null),
|
||||
})
|
||||
await unavailable.controller.handleSkillsCommand(createCommandContext().ctx)
|
||||
expect(unavailable.sent.at(-1)?.text).toContain('当前项目可用 Skills')
|
||||
|
||||
const callback = createCommandContext()
|
||||
await unavailable.controller.handleSelectionCallback(callback.ctx, {
|
||||
kind: 'skill',
|
||||
action: 'pick',
|
||||
index: 0,
|
||||
})
|
||||
|
||||
expect(unavailable.sentUserMessages).toEqual([])
|
||||
expect(callback.edits[0]).toContain('会话已失效')
|
||||
})
|
||||
|
||||
it('reports a disconnected bridge instead of dropping a selected skill', async () => {
|
||||
const disconnected = createController({
|
||||
sendUserMessage: mock(() => false),
|
||||
})
|
||||
await disconnected.controller.handleSkillsCommand(createCommandContext().ctx)
|
||||
|
||||
const callback = createCommandContext()
|
||||
await disconnected.controller.handleSelectionCallback(callback.ctx, {
|
||||
kind: 'skill',
|
||||
action: 'pick',
|
||||
index: 0,
|
||||
})
|
||||
|
||||
expect(callback.edits[0]).toContain('发送失败')
|
||||
})
|
||||
|
||||
it('resumes a historical project session through two callbacks', async () => {
|
||||
@@ -585,6 +690,8 @@ describe('Telegram command controller helpers', () => {
|
||||
|
||||
it('creates a controller from runtime dependencies', async () => {
|
||||
const events: string[] = []
|
||||
let allowPermissionUser = true
|
||||
let sendPermissionSucceeds = true
|
||||
const controller = createTelegramRuntimeCommandController({
|
||||
botApi: { sendMessage: mock(async () => {}) },
|
||||
httpClient: createController().deps.httpClient,
|
||||
@@ -597,12 +704,20 @@ describe('Telegram command controller helpers', () => {
|
||||
void handler({ type: 'connected' })
|
||||
},
|
||||
waitForOpen: mock(async () => true),
|
||||
sendUserMessage: (chatId, content) => {
|
||||
events.push(`send:${chatId}:${content}`)
|
||||
return true
|
||||
},
|
||||
sendPermissionResponse: (chatId, requestId, allowed, rule) => {
|
||||
events.push(`permit:${chatId}:${requestId}:${allowed}:${rule ?? ''}`)
|
||||
return sendPermissionSucceeds
|
||||
},
|
||||
},
|
||||
sessionStore: {
|
||||
set: (chatId, sessionId, workDir) => events.push(`store:${chatId}:${sessionId}:${workDir}`),
|
||||
delete: (chatId) => events.push(`delete:${chatId}`),
|
||||
},
|
||||
isAllowedUser: () => true,
|
||||
isAllowedUser: () => allowPermissionUser,
|
||||
ensureExistingSession: mock(async () => ({ sessionId: 'active', workDir: '/work/repo' })),
|
||||
clearTransientChatState: (chatId) => events.push(`clear:${chatId}`),
|
||||
handleServerMessage: (chatId, msg) => {
|
||||
@@ -623,8 +738,47 @@ describe('Telegram command controller helpers', () => {
|
||||
action: 'pick',
|
||||
index: 0,
|
||||
})
|
||||
const permissionCtx = createCommandContext()
|
||||
await controller.handlePermissionCallback(permissionCtx.ctx, {
|
||||
requestId: 'req-1',
|
||||
allowed: true,
|
||||
}, new Map([['42', new Set(['req-1'])]]), (chatId) => events.push(`decrement:${chatId}`))
|
||||
|
||||
expect(events).toContain('model:42:model-x')
|
||||
expect(events).toContain('message:42:connected')
|
||||
expect(events).toContain('permit:42:req-1:true:')
|
||||
expect(events).toContain('decrement:42')
|
||||
expect(permissionCtx.edits[0]).toContain('已允许')
|
||||
|
||||
const missingIdentityCtx = createCommandContext()
|
||||
delete (missingIdentityCtx.ctx as any).from
|
||||
await expect(controller.handlePermissionCallback(missingIdentityCtx.ctx, {
|
||||
requestId: 'missing-user',
|
||||
allowed: true,
|
||||
}, new Map(), () => {})).resolves.toBe('unauthorized')
|
||||
|
||||
allowPermissionUser = false
|
||||
const unauthorizedCtx = createCommandContext()
|
||||
await expect(controller.handlePermissionCallback(unauthorizedCtx.ctx, {
|
||||
requestId: 'unauthorized',
|
||||
allowed: true,
|
||||
}, new Map([['42', new Set(['unauthorized'])]]), () => {})).resolves.toBe('unauthorized')
|
||||
expect(unauthorizedCtx.answers).toContain('未授权')
|
||||
|
||||
allowPermissionUser = true
|
||||
const expiredCtx = createCommandContext()
|
||||
await expect(controller.handlePermissionCallback(expiredCtx.ctx, {
|
||||
requestId: 'expired',
|
||||
allowed: true,
|
||||
}, new Map(), () => {})).resolves.toBe('not_pending')
|
||||
expect(expiredCtx.answers).toContain('权限请求已失效')
|
||||
|
||||
sendPermissionSucceeds = false
|
||||
const failedCtx = createCommandContext()
|
||||
await expect(controller.handlePermissionCallback(failedCtx.ctx, {
|
||||
requestId: 'send-failed',
|
||||
allowed: false,
|
||||
}, new Map([['42', new Set(['send-failed'])]]), () => {})).resolves.toBe('send_failed')
|
||||
expect(failedCtx.answers).toContain('权限响应发送失败')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -180,22 +180,4 @@ describe('Telegram message formatting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('whitelist logic', () => {
|
||||
it('empty allowedUsers means allow all', () => {
|
||||
const allowedUsers: number[] = []
|
||||
const isAllowed = (userId: number) =>
|
||||
allowedUsers.length === 0 || allowedUsers.includes(userId)
|
||||
expect(isAllowed(12345)).toBe(true)
|
||||
expect(isAllowed(99999)).toBe(true)
|
||||
})
|
||||
|
||||
it('non-empty allowedUsers filters correctly', () => {
|
||||
const allowedUsers = [111, 222]
|
||||
const isAllowed = (userId: number) =>
|
||||
allowedUsers.length === 0 || allowedUsers.includes(userId)
|
||||
expect(isAllowed(111)).toBe(true)
|
||||
expect(isAllowed(222)).toBe(true)
|
||||
expect(isAllowed(333)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { formatImHelp } from '../common/format.js'
|
||||
import {
|
||||
formatPermissionDecisionStatus,
|
||||
type PermissionDecision,
|
||||
} from '../common/permission.js'
|
||||
import type {
|
||||
AdapterHttpClient,
|
||||
ProviderSummary,
|
||||
@@ -71,10 +75,37 @@ export type TelegramCommandControllerDeps = {
|
||||
connectBridgeSession: (chatId: string, sessionId: string) => void
|
||||
onBridgeServerMessage: (chatId: string) => void
|
||||
waitForBridgeOpen: (chatId: string) => Promise<boolean>
|
||||
sendUserMessage: (chatId: string, content: string) => boolean
|
||||
setRuntimeModel: RuntimeModelSetter
|
||||
}
|
||||
|
||||
export type TelegramCommandController = ReturnType<typeof createTelegramCommandController>
|
||||
export type TelegramRuntimeCommandController = TelegramCommandController & {
|
||||
handlePermissionCallback: (
|
||||
ctx: TelegramCommandContext,
|
||||
decision: PermissionDecision,
|
||||
pendingPermissions: Map<string, Set<string>>,
|
||||
onResolved: (chatId: string) => void,
|
||||
) => Promise<TelegramPermissionCallbackResult>
|
||||
}
|
||||
export type TelegramPermissionCallbackResult =
|
||||
| 'sent'
|
||||
| 'unauthorized'
|
||||
| 'not_pending'
|
||||
| 'send_failed'
|
||||
|
||||
export function telegramMessageDedupKey(chatId: string, messageId: number): string {
|
||||
return `telegram:${chatId}:${messageId}`
|
||||
}
|
||||
|
||||
export function shouldProcessTelegramMessage(
|
||||
dedup: { tryRecord: (key: string) => boolean },
|
||||
chatId: string,
|
||||
messageId: number | undefined,
|
||||
): boolean {
|
||||
return messageId !== undefined &&
|
||||
dedup.tryRecord(telegramMessageDedupKey(chatId, messageId))
|
||||
}
|
||||
|
||||
export type TelegramCommandRegistrar = {
|
||||
command: (command: string, handler: (ctx: TelegramCommandContext) => unknown) => unknown
|
||||
@@ -102,6 +133,83 @@ export function registerAuthorizedTelegramCommand(
|
||||
})())
|
||||
}
|
||||
|
||||
export function resolveTelegramPermissionCallback(params: {
|
||||
chatId: string
|
||||
userId: number
|
||||
decision: PermissionDecision
|
||||
pendingRequestIds?: Set<string>
|
||||
isAllowedUser: (userId: number) => boolean
|
||||
sendPermissionResponse: (
|
||||
chatId: string,
|
||||
requestId: string,
|
||||
allowed: boolean,
|
||||
rule?: string,
|
||||
) => boolean
|
||||
}): TelegramPermissionCallbackResult {
|
||||
if (!params.isAllowedUser(params.userId)) return 'unauthorized'
|
||||
if (!params.pendingRequestIds?.has(params.decision.requestId)) return 'not_pending'
|
||||
|
||||
const sent = params.sendPermissionResponse(
|
||||
params.chatId,
|
||||
params.decision.requestId,
|
||||
params.decision.allowed,
|
||||
params.decision.rule,
|
||||
)
|
||||
if (!sent) return 'send_failed'
|
||||
|
||||
params.pendingRequestIds.delete(params.decision.requestId)
|
||||
return 'sent'
|
||||
}
|
||||
|
||||
async function handleTelegramPermissionCallback(
|
||||
ctx: TelegramCommandContext,
|
||||
decision: PermissionDecision,
|
||||
deps: Pick<TelegramRuntimeCommandControllerDeps, 'isAllowedUser'> & {
|
||||
pendingPermissions: Map<string, Set<string>>
|
||||
sendPermissionResponse: (
|
||||
chatId: string,
|
||||
requestId: string,
|
||||
allowed: boolean,
|
||||
rule?: string,
|
||||
) => boolean
|
||||
onResolved: (chatId: string) => void
|
||||
},
|
||||
): Promise<TelegramPermissionCallbackResult> {
|
||||
const callbackChatId = ctx.callbackQuery?.message?.chat.id
|
||||
const callbackUserId = ctx.from?.id
|
||||
if (callbackChatId === undefined || callbackUserId === undefined) {
|
||||
await ctx.answerCallbackQuery('未授权').catch(() => {})
|
||||
return 'unauthorized'
|
||||
}
|
||||
|
||||
const chatId = String(callbackChatId)
|
||||
const result = resolveTelegramPermissionCallback({
|
||||
chatId,
|
||||
userId: callbackUserId,
|
||||
decision,
|
||||
pendingRequestIds: deps.pendingPermissions.get(chatId),
|
||||
isAllowedUser: deps.isAllowedUser,
|
||||
sendPermissionResponse: deps.sendPermissionResponse,
|
||||
})
|
||||
if (result !== 'sent') {
|
||||
const message = result === 'unauthorized'
|
||||
? '未授权'
|
||||
: result === 'not_pending'
|
||||
? '权限请求已失效'
|
||||
: '权限响应发送失败'
|
||||
await ctx.answerCallbackQuery(message).catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
deps.onResolved(chatId)
|
||||
const statusText = formatPermissionDecisionStatus(decision)
|
||||
await ctx.editMessageText(
|
||||
`${ctx.callbackQuery?.message?.text ?? ''}\n\n${statusText}`,
|
||||
).catch(() => {})
|
||||
await ctx.answerCallbackQuery(statusText)
|
||||
return 'sent'
|
||||
}
|
||||
|
||||
export type TelegramRuntimeCommandControllerDeps = {
|
||||
botApi: TelegramSendApi
|
||||
httpClient: AdapterHttpClient
|
||||
@@ -111,6 +219,13 @@ export type TelegramRuntimeCommandControllerDeps = {
|
||||
connectSession: (chatId: string, sessionId: string) => void
|
||||
onServerMessage: (chatId: string, handler: (msg: unknown) => void | Promise<void>) => void
|
||||
waitForOpen: (chatId: string) => Promise<boolean>
|
||||
sendUserMessage: (chatId: string, content: string) => boolean
|
||||
sendPermissionResponse: (
|
||||
chatId: string,
|
||||
requestId: string,
|
||||
allowed: boolean,
|
||||
rule?: string,
|
||||
) => boolean
|
||||
}
|
||||
sessionStore: {
|
||||
set: (chatId: string, sessionId: string, workDir: string) => void
|
||||
@@ -125,8 +240,8 @@ export type TelegramRuntimeCommandControllerDeps = {
|
||||
|
||||
export function createTelegramRuntimeCommandController(
|
||||
deps: TelegramRuntimeCommandControllerDeps,
|
||||
): TelegramCommandController {
|
||||
return createTelegramCommandController({
|
||||
): TelegramRuntimeCommandController {
|
||||
const controller = createTelegramCommandController({
|
||||
api: deps.botApi,
|
||||
httpClient: deps.httpClient,
|
||||
defaultWorkDir: deps.defaultWorkDir,
|
||||
@@ -142,8 +257,23 @@ export function createTelegramRuntimeCommandController(
|
||||
(msg) => deps.handleServerMessage(chatId, msg),
|
||||
),
|
||||
waitForBridgeOpen: (chatId) => deps.bridge.waitForOpen(chatId),
|
||||
sendUserMessage: (chatId, content) => deps.bridge.sendUserMessage(chatId, content),
|
||||
setRuntimeModel: deps.setRuntimeModel,
|
||||
})
|
||||
return {
|
||||
...controller,
|
||||
handlePermissionCallback: (ctx, decision, pendingPermissions, onResolved) => handleTelegramPermissionCallback(
|
||||
ctx,
|
||||
decision,
|
||||
{
|
||||
isAllowedUser: deps.isAllowedUser,
|
||||
pendingPermissions,
|
||||
sendPermissionResponse: (chatId, requestId, allowed, rule) =>
|
||||
deps.bridge.sendPermissionResponse(chatId, requestId, allowed, rule),
|
||||
onResolved,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTelegramExtendedCommands(
|
||||
@@ -374,12 +504,27 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD
|
||||
): Promise<void> => {
|
||||
const chatId = getCallbackChatId(ctx)
|
||||
if (!chatId) return
|
||||
|
||||
const stored = await deps.ensureExistingSession(chatId)
|
||||
if (!stored) {
|
||||
pendingSelections.delete(chatId)
|
||||
await ctx.editMessageText('⚠️ 会话已失效,请发送 /new 重新选择项目后再调用 Skill。')
|
||||
return
|
||||
}
|
||||
|
||||
const invocation = `/${item.value}`
|
||||
if (!deps.sendUserMessage(chatId, invocation)) {
|
||||
await ctx.editMessageText('⚠️ Skill 发送失败,连接可能已断开。请发送 /new 重新连接会话。')
|
||||
return
|
||||
}
|
||||
|
||||
pendingSelections.delete(chatId)
|
||||
await ctx.editMessageText([
|
||||
`Skill:${item.label}`,
|
||||
`✅ 已调用 Skill:${item.label}`,
|
||||
invocation,
|
||||
item.description,
|
||||
'',
|
||||
'可以直接描述任务让 Agent 自动选择,也可以在桌面端 /skills 查看详情。',
|
||||
'任务已发送给当前 Agent,会继续使用这条会话的上下文、工具和权限设置。',
|
||||
].filter(Boolean).join('\n'))
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,9 @@ import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
|
||||
import type { AttachmentRef } from '../common/ws-bridge.js'
|
||||
import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
|
||||
import type { PendingUpload } from '../common/attachment/attachment-types.js'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { sendSafeOutboundImage } from '../common/attachment/outbound-image.js'
|
||||
import { syncTelegramBotCommands } from './menu.js'
|
||||
import { createTelegramRuntimeCommandController, registerAuthorizedTelegramCommand, registerTelegramExtendedCommands, tryHandleTelegramSelectionCallback } from './commands.js'
|
||||
import { createTelegramRuntimeCommandController, registerAuthorizedTelegramCommand, registerTelegramExtendedCommands, shouldProcessTelegramMessage, tryHandleTelegramSelectionCallback } from './commands.js'
|
||||
|
||||
const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096
|
||||
const TELEGRAM_STREAMING_TEXT_LIMIT = TELEGRAM_TEXT_LIMIT - 2 // reserve room for cursor
|
||||
@@ -352,35 +352,8 @@ async function showProjectPicker(chatId: string): Promise<void> {
|
||||
async function dispatchOutboundMedia(chatId: string, pending: PendingUpload): Promise<void> {
|
||||
const numericChatId = Number(chatId)
|
||||
try {
|
||||
let buffer: Buffer
|
||||
let mime = 'image/png'
|
||||
switch (pending.source.kind) {
|
||||
case 'base64': {
|
||||
buffer = Buffer.from(pending.source.data, 'base64')
|
||||
mime = pending.source.mime
|
||||
break
|
||||
}
|
||||
case 'path': {
|
||||
buffer = await fs.readFile(pending.source.path)
|
||||
mime = pending.source.mime ?? 'image/png'
|
||||
break
|
||||
}
|
||||
case 'url': {
|
||||
const resp = await fetch(pending.source.url)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`fetch ${pending.source.url} -> ${resp.status}`)
|
||||
}
|
||||
buffer = Buffer.from(await resp.arrayBuffer())
|
||||
mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png'
|
||||
break
|
||||
}
|
||||
}
|
||||
const check = checkAttachmentLimit('image', buffer.length, mime)
|
||||
if (!check.ok) {
|
||||
console.warn('[Telegram] Outbound image rejected:', check.hint)
|
||||
return
|
||||
}
|
||||
await media.sendPhoto(numericChatId, buffer, pending.alt)
|
||||
const loaded = await sendSafeOutboundImage(pending, sessionStore.get(chatId)?.workDir, (buffer) => media.sendPhoto(numericChatId, buffer, pending.alt))
|
||||
if (!loaded.ok) console.warn('[Telegram] Outbound image rejected:', loaded.reason)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[Telegram] dispatchOutboundMedia failed:',
|
||||
@@ -677,9 +650,9 @@ async function routeUserMessage(
|
||||
attachments: AttachmentRef[],
|
||||
): Promise<void> {
|
||||
if (!ctx.from || ctx.chat?.type !== 'private') return
|
||||
if (!dedup.tryRecord(String(ctx.message?.message_id))) return
|
||||
|
||||
const chatId = String(ctx.chat.id)
|
||||
if (!shouldProcessTelegramMessage(dedup, chatId, ctx.message?.message_id)) return
|
||||
|
||||
const userId = ctx.from.id
|
||||
|
||||
if (!isAllowedUser('telegram', userId)) {
|
||||
@@ -813,28 +786,14 @@ bot.on('callback_query:data', async (ctx) => {
|
||||
|
||||
const decision = parsePermitCallbackData(data)
|
||||
if (!decision) return
|
||||
const chatId = String(ctx.callbackQuery.message?.chat.id)
|
||||
|
||||
bridge.sendPermissionResponse(chatId, decision.requestId, decision.allowed, decision.rule)
|
||||
const runtime = getRuntimeState(chatId)
|
||||
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
|
||||
pendingPermissions.get(chatId)?.delete(decision.requestId)
|
||||
|
||||
const statusText = formatPermissionDecisionStatus(decision)
|
||||
try {
|
||||
await ctx.editMessageText(
|
||||
ctx.callbackQuery.message?.text + `\n\n${statusText}`,
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
|
||||
await ctx.answerCallbackQuery(statusText)
|
||||
await commandController.handlePermissionCallback(ctx, decision, pendingPermissions, (chatId) => getRuntimeState(chatId).pendingPermissionCount = Math.max(0, getRuntimeState(chatId).pendingPermissionCount - 1))
|
||||
})
|
||||
|
||||
// ---------- start ----------
|
||||
|
||||
console.log('[Telegram] Starting bot...')
|
||||
console.log(`[Telegram] Server: ${config.serverUrl}`)
|
||||
console.log(`[Telegram] Allowed users: ${config.telegram.allowedUsers.length === 0 ? 'all' : config.telegram.allowedUsers.join(', ')}`)
|
||||
console.log(`[Telegram] Allowed users: ${config.telegram.allowedUsers.length === 0 ? 'paired users only' : config.telegram.allowedUsers.join(', ')}`)
|
||||
|
||||
void syncTelegramBotCommands(bot.api).then(() => console.log('[Telegram] Command menu synced')).catch((err) => console.warn('[Telegram] Command menu sync failed:', err instanceof Error ? err.message : err))
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ export const TELEGRAM_BOT_COMMANDS: TelegramBotCommand[] = [
|
||||
{ command: 'stop', description: '停止当前生成' },
|
||||
{ command: 'provider', description: '切换 Provider' },
|
||||
{ command: 'model', description: '切换模型' },
|
||||
{ command: 'skills', description: '查看 Skills' },
|
||||
{ command: 'skills', description: '查看并调用 Skills' },
|
||||
{ command: 'allow', description: '允许权限请求' },
|
||||
{ command: 'always', description: '永久允许权限请求' },
|
||||
{ command: 'always', description: '本会话永久允许' },
|
||||
{ command: 'deny', description: '拒绝权限请求' },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user