bingocode 1.0.41 → 1.1.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/bingo-win.cjs +2 -1
- package/bin/bingocode-win.cjs +2 -1
- package/bin/claude-win.cjs +2 -1
- package/bun.lock +1716 -0
- package/package.json +14 -2
- package/src/server/config/providers.yaml +1 -1
- package/src/server/proxy/transform/anthropicToOpenaiChat.ts +11 -4
- package/adapters/README.md +0 -87
- package/adapters/common/__tests__/chat-queue.test.ts +0 -61
- package/adapters/common/__tests__/format.test.ts +0 -148
- package/adapters/common/__tests__/http-client.test.ts +0 -105
- package/adapters/common/__tests__/message-buffer.test.ts +0 -84
- package/adapters/common/__tests__/message-dedup.test.ts +0 -57
- package/adapters/common/__tests__/session-store.test.ts +0 -62
- package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
- package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
- package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
- package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
- package/adapters/common/attachment/attachment-limits.ts +0 -58
- package/adapters/common/attachment/attachment-store.ts +0 -121
- package/adapters/common/attachment/attachment-types.ts +0 -29
- package/adapters/common/attachment/image-block-watcher.ts +0 -94
- package/adapters/common/chat-queue.ts +0 -24
- package/adapters/common/config.ts +0 -96
- package/adapters/common/format.ts +0 -229
- package/adapters/common/http-client.ts +0 -107
- package/adapters/common/message-buffer.ts +0 -91
- package/adapters/common/message-dedup.ts +0 -57
- package/adapters/common/pairing.ts +0 -149
- package/adapters/common/session-store.ts +0 -60
- package/adapters/common/ws-bridge.ts +0 -282
- package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
- package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
- package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
- package/adapters/feishu/__tests__/feishu.test.ts +0 -907
- package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
- package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
- package/adapters/feishu/__tests__/media.test.ts +0 -120
- package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
- package/adapters/feishu/card-errors.ts +0 -151
- package/adapters/feishu/cardkit.ts +0 -294
- package/adapters/feishu/extract-payload.ts +0 -95
- package/adapters/feishu/flush-controller.ts +0 -149
- package/adapters/feishu/index.ts +0 -1275
- package/adapters/feishu/markdown-style.ts +0 -212
- package/adapters/feishu/media.ts +0 -176
- package/adapters/feishu/streaming-card.ts +0 -612
- package/adapters/package.json +0 -23
- package/adapters/telegram/__tests__/media.test.ts +0 -86
- package/adapters/telegram/__tests__/telegram.test.ts +0 -115
- package/adapters/telegram/index.ts +0 -754
- package/adapters/telegram/media.ts +0 -89
- package/adapters/tsconfig.json +0 -18
- package/runtime/mac_helper.py +0 -775
- package/runtime/requirements-win.txt +0 -7
- package/runtime/requirements.txt +0 -6
- package/runtime/test_helpers.py +0 -322
- package/runtime/win_helper.py +0 -723
- package/scripts/count-app-loc.ts +0 -256
- package/scripts/release.ts +0 -130
- package/start-cli.bat +0 -7
- package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
- package/stubs/color-diff-napi.ts +0 -45
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
-
import { WsBridge } from '../ws-bridge.js'
|
|
3
|
-
import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
|
|
4
|
-
|
|
5
|
-
describe('WsBridge', () => {
|
|
6
|
-
let bridge: WsBridge
|
|
7
|
-
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
bridge = new WsBridge('ws://127.0.0.1:19999', 'test')
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
afterEach(() => {
|
|
13
|
-
bridge.destroy()
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('connectSession connects with provided sessionId', () => {
|
|
17
|
-
const result = bridge.connectSession('chat-1', 'my-uuid-session-id')
|
|
18
|
-
expect(result).toBe(true)
|
|
19
|
-
expect(bridge.hasSession('chat-1')).toBe(true)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('connectSession for different chatIds creates separate sessions', () => {
|
|
23
|
-
bridge.connectSession('chat-1', 'uuid-1')
|
|
24
|
-
bridge.connectSession('chat-2', 'uuid-2')
|
|
25
|
-
expect(bridge.hasSession('chat-1')).toBe(true)
|
|
26
|
-
expect(bridge.hasSession('chat-2')).toBe(true)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('resetSession removes the session', () => {
|
|
30
|
-
bridge.connectSession('chat-reset', 'uuid-reset')
|
|
31
|
-
bridge.resetSession('chat-reset')
|
|
32
|
-
expect(bridge.hasSession('chat-reset')).toBe(false)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('sendUserMessage returns false when no open connection', () => {
|
|
36
|
-
bridge.connectSession('chat-offline', 'uuid-offline')
|
|
37
|
-
expect(bridge.sendUserMessage('chat-offline', 'hello')).toBe(false)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('sendPermissionResponse returns false when no open connection', () => {
|
|
41
|
-
bridge.connectSession('chat-perm', 'uuid-perm')
|
|
42
|
-
expect(bridge.sendPermissionResponse('chat-perm', 'req-1', true)).toBe(false)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('sendStopGeneration returns false when no open connection', () => {
|
|
46
|
-
bridge.connectSession('chat-stop', 'uuid-stop')
|
|
47
|
-
expect(bridge.sendStopGeneration('chat-stop')).toBe(false)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('destroy cleans up all sessions', () => {
|
|
51
|
-
bridge.connectSession('a', 'uuid-a')
|
|
52
|
-
bridge.connectSession('b', 'uuid-b')
|
|
53
|
-
bridge.destroy()
|
|
54
|
-
expect(bridge.hasSession('a')).toBe(false)
|
|
55
|
-
expect(bridge.hasSession('b')).toBe(false)
|
|
56
|
-
})
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Integration: per-chat handler serialization
|
|
61
|
-
//
|
|
62
|
-
// Reproduces the feishu text→tool→text race: a slow handler on msg 1 must
|
|
63
|
-
// complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale
|
|
64
|
-
// state msg 1's continuation is about to clear.
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
describe('WsBridge: handler serialization', () => {
|
|
68
|
-
let server: WebSocketServer
|
|
69
|
-
let port: number
|
|
70
|
-
let connections: WsServerSocket[]
|
|
71
|
-
let serverUrl: string
|
|
72
|
-
|
|
73
|
-
beforeEach(async () => {
|
|
74
|
-
connections = []
|
|
75
|
-
// port 0 → let the OS pick a free one
|
|
76
|
-
server = new WebSocketServer({ port: 0 })
|
|
77
|
-
server.on('connection', (ws) => {
|
|
78
|
-
connections.push(ws)
|
|
79
|
-
})
|
|
80
|
-
await new Promise<void>((resolve) => server.on('listening', () => resolve()))
|
|
81
|
-
port = (server.address() as { port: number }).port
|
|
82
|
-
serverUrl = `ws://127.0.0.1:${port}`
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
afterEach(async () => {
|
|
86
|
-
// Forcibly kill any server-side sockets (not graceful close) so
|
|
87
|
-
// WebSocketServer.close() doesn't wait for client FIN.
|
|
88
|
-
for (const ws of connections) {
|
|
89
|
-
try { ws.terminate() } catch {}
|
|
90
|
-
}
|
|
91
|
-
await new Promise<void>((resolve) => {
|
|
92
|
-
const t = setTimeout(() => resolve(), 500) // hard cap
|
|
93
|
-
server.close(() => {
|
|
94
|
-
clearTimeout(t)
|
|
95
|
-
resolve()
|
|
96
|
-
})
|
|
97
|
-
})
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('processes handler calls in strict FIFO order per chatId', async () => {
|
|
101
|
-
const bridge = new WsBridge(serverUrl, 'test')
|
|
102
|
-
const events: string[] = []
|
|
103
|
-
|
|
104
|
-
// The handler simulates an async side effect that takes varying time.
|
|
105
|
-
// If handlers ran concurrently, fast msgs could finish before slow ones,
|
|
106
|
-
// producing an out-of-order `events` array.
|
|
107
|
-
bridge.onServerMessage('chat-1', async (msg: any) => {
|
|
108
|
-
const tag = msg.tag as string
|
|
109
|
-
const delay = msg.delay as number
|
|
110
|
-
events.push(`start:${tag}`)
|
|
111
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
112
|
-
events.push(`end:${tag}`)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
bridge.connectSession('chat-1', 'sess-1')
|
|
116
|
-
const ok = await bridge.waitForOpen('chat-1')
|
|
117
|
-
expect(ok).toBe(true)
|
|
118
|
-
expect(connections.length).toBe(1)
|
|
119
|
-
const serverWs = connections[0]!
|
|
120
|
-
|
|
121
|
-
// Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast.
|
|
122
|
-
// With serialization: start:1, end:1, start:2, end:2, start:3, end:3
|
|
123
|
-
// Without serialization: start:1, start:2, start:3, end:2, end:3, end:1
|
|
124
|
-
serverWs.send(JSON.stringify({ tag: '1', delay: 40 }))
|
|
125
|
-
serverWs.send(JSON.stringify({ tag: '2', delay: 5 }))
|
|
126
|
-
serverWs.send(JSON.stringify({ tag: '3', delay: 5 }))
|
|
127
|
-
|
|
128
|
-
// Wait long enough for all three handlers to run serially
|
|
129
|
-
await new Promise((r) => setTimeout(r, 200))
|
|
130
|
-
|
|
131
|
-
expect(events).toEqual([
|
|
132
|
-
'start:1', 'end:1',
|
|
133
|
-
'start:2', 'end:2',
|
|
134
|
-
'start:3', 'end:3',
|
|
135
|
-
])
|
|
136
|
-
|
|
137
|
-
bridge.destroy()
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('handler error does not break the chain (subsequent messages still run)', async () => {
|
|
141
|
-
const bridge = new WsBridge(serverUrl, 'test')
|
|
142
|
-
const events: string[] = []
|
|
143
|
-
|
|
144
|
-
bridge.onServerMessage('chat-err', async (msg: any) => {
|
|
145
|
-
if (msg.throw) {
|
|
146
|
-
events.push('throwing')
|
|
147
|
-
throw new Error('boom')
|
|
148
|
-
}
|
|
149
|
-
events.push(`ok:${msg.tag}`)
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
bridge.connectSession('chat-err', 'sess-err')
|
|
153
|
-
await bridge.waitForOpen('chat-err')
|
|
154
|
-
const serverWs = connections[0]!
|
|
155
|
-
|
|
156
|
-
serverWs.send(JSON.stringify({ throw: true }))
|
|
157
|
-
serverWs.send(JSON.stringify({ tag: 'after' }))
|
|
158
|
-
|
|
159
|
-
await new Promise((r) => setTimeout(r, 80))
|
|
160
|
-
|
|
161
|
-
expect(events).toEqual(['throwing', 'ok:after'])
|
|
162
|
-
|
|
163
|
-
bridge.destroy()
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it('resetSession clears the handler chain', async () => {
|
|
167
|
-
const bridge = new WsBridge(serverUrl, 'test')
|
|
168
|
-
bridge.onServerMessage('chat-reset', () => {})
|
|
169
|
-
bridge.connectSession('chat-reset', 'sess-reset')
|
|
170
|
-
await bridge.waitForOpen('chat-reset')
|
|
171
|
-
|
|
172
|
-
bridge.resetSession('chat-reset')
|
|
173
|
-
expect(bridge.hasSession('chat-reset')).toBe(false)
|
|
174
|
-
|
|
175
|
-
bridge.destroy()
|
|
176
|
-
})
|
|
177
|
-
})
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'bun:test'
|
|
2
|
-
import {
|
|
3
|
-
checkAttachmentLimit,
|
|
4
|
-
IMAGE_MAX_BYTES,
|
|
5
|
-
FILE_MAX_BYTES,
|
|
6
|
-
IMAGE_MIME_WHITELIST,
|
|
7
|
-
} from '../attachment-limits.js'
|
|
8
|
-
|
|
9
|
-
describe('checkAttachmentLimit', () => {
|
|
10
|
-
it('accepts a 1 MB PNG image', () => {
|
|
11
|
-
const result = checkAttachmentLimit('image', 1024 * 1024, 'image/png')
|
|
12
|
-
expect(result.ok).toBe(true)
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('rejects an 11 MB image as too_large', () => {
|
|
16
|
-
const result = checkAttachmentLimit('image', 11 * 1024 * 1024, 'image/png')
|
|
17
|
-
expect(result.ok).toBe(false)
|
|
18
|
-
if (!result.ok) {
|
|
19
|
-
expect(result.reason).toBe('too_large')
|
|
20
|
-
expect(result.hint).toContain('10')
|
|
21
|
-
}
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('rejects an unsupported image mime', () => {
|
|
25
|
-
const result = checkAttachmentLimit('image', 500_000, 'image/svg+xml')
|
|
26
|
-
expect(result.ok).toBe(false)
|
|
27
|
-
if (!result.ok) expect(result.reason).toBe('unsupported_mime')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('rejects image/heic (not supported by Claude API)', () => {
|
|
31
|
-
const result = checkAttachmentLimit('image', 500_000, 'image/heic')
|
|
32
|
-
expect(result.ok).toBe(false)
|
|
33
|
-
if (!result.ok) expect(result.reason).toBe('unsupported_mime')
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('accepts a 10 MB PDF file', () => {
|
|
37
|
-
const result = checkAttachmentLimit('file', 10 * 1024 * 1024, 'application/pdf')
|
|
38
|
-
expect(result.ok).toBe(true)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('rejects a 31 MB file as too_large', () => {
|
|
42
|
-
const result = checkAttachmentLimit('file', 31 * 1024 * 1024, 'application/pdf')
|
|
43
|
-
expect(result.ok).toBe(false)
|
|
44
|
-
if (!result.ok) expect(result.reason).toBe('too_large')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('exposes the limits as exports', () => {
|
|
48
|
-
expect(IMAGE_MAX_BYTES).toBe(10 * 1024 * 1024)
|
|
49
|
-
expect(FILE_MAX_BYTES).toBe(30 * 1024 * 1024)
|
|
50
|
-
expect(IMAGE_MIME_WHITELIST).toContain('image/png')
|
|
51
|
-
})
|
|
52
|
-
})
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
-
import * as fs from 'node:fs/promises'
|
|
3
|
-
import * as fsSync from 'node:fs'
|
|
4
|
-
import * as path from 'node:path'
|
|
5
|
-
import * as os from 'node:os'
|
|
6
|
-
import { AttachmentStore } from '../attachment-store.js'
|
|
7
|
-
|
|
8
|
-
let tmpRoot: string
|
|
9
|
-
|
|
10
|
-
beforeEach(async () => {
|
|
11
|
-
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'att-store-test-'))
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
afterEach(async () => {
|
|
15
|
-
await fs.rm(tmpRoot, { recursive: true, force: true })
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
describe('AttachmentStore', () => {
|
|
19
|
-
it('writes a buffer and returns the absolute path', async () => {
|
|
20
|
-
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
|
21
|
-
const target = store.resolvePath('feishu', 'sess-1', 'hello.png')
|
|
22
|
-
const written = await store.write(target, Buffer.from('PNGDATA'))
|
|
23
|
-
expect(path.isAbsolute(written)).toBe(true)
|
|
24
|
-
const content = await fs.readFile(written)
|
|
25
|
-
expect(content.toString()).toBe('PNGDATA')
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('writes under {root}/{platform}/{sessionId}/', async () => {
|
|
29
|
-
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
|
30
|
-
const target = store.resolvePath('telegram', 'sess-42', 'foo.pdf')
|
|
31
|
-
expect(target).toContain(path.join('telegram', 'sess-42'))
|
|
32
|
-
expect(target.endsWith('foo.pdf')).toBe(true)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('sanitizes unsafe filenames (strips path separators and ..)', async () => {
|
|
36
|
-
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
|
37
|
-
const target = store.resolvePath('feishu', 'sess-1', '../../etc/passwd')
|
|
38
|
-
// The resulting target must still live inside the store root.
|
|
39
|
-
const root = path.resolve(tmpRoot)
|
|
40
|
-
expect(path.resolve(target).startsWith(root)).toBe(true)
|
|
41
|
-
expect(path.basename(target)).not.toContain('..')
|
|
42
|
-
expect(path.basename(target)).not.toContain('/')
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('collapses name collisions by prefixing timestamps', async () => {
|
|
46
|
-
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
|
47
|
-
const a = store.resolvePath('feishu', 'sess-1', 'image.png')
|
|
48
|
-
await store.write(a, Buffer.from('first'))
|
|
49
|
-
const b = store.resolvePath('feishu', 'sess-1', 'image.png')
|
|
50
|
-
expect(b).not.toBe(a)
|
|
51
|
-
await store.write(b, Buffer.from('second'))
|
|
52
|
-
const contentB = await fs.readFile(b)
|
|
53
|
-
expect(contentB.toString()).toBe('second')
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('gc() removes files older than retentionMs and reports counts', async () => {
|
|
57
|
-
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 50 })
|
|
58
|
-
const target = store.resolvePath('feishu', 'sess-1', 'stale.png')
|
|
59
|
-
await store.write(target, Buffer.from('STALE'))
|
|
60
|
-
// Age the file manually
|
|
61
|
-
const past = new Date(Date.now() - 10_000)
|
|
62
|
-
await fs.utimes(target, past, past)
|
|
63
|
-
const result = await store.gc()
|
|
64
|
-
expect(result.removed).toBe(1)
|
|
65
|
-
expect(result.bytes).toBe(5)
|
|
66
|
-
await expect(fs.access(target)).rejects.toThrow()
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('gc() keeps fresh files', async () => {
|
|
70
|
-
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
|
71
|
-
const target = store.resolvePath('feishu', 'sess-1', 'fresh.png')
|
|
72
|
-
await store.write(target, Buffer.from('FRESH'))
|
|
73
|
-
const result = await store.gc()
|
|
74
|
-
expect(result.removed).toBe(0)
|
|
75
|
-
await fs.access(target)
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('resolvePath under heavy collision pressure returns unique paths', () => {
|
|
79
|
-
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
|
80
|
-
// First create a file so subsequent resolves hit the collision branch
|
|
81
|
-
const a = store.resolvePath('feishu', 'sess-1', 'race.png')
|
|
82
|
-
fsSync.writeFileSync(a, 'first')
|
|
83
|
-
// Collect 50 resolved paths in a tight loop — none should clash
|
|
84
|
-
const seen = new Set<string>()
|
|
85
|
-
for (let i = 0; i < 50; i++) {
|
|
86
|
-
seen.add(store.resolvePath('feishu', 'sess-1', 'race.png'))
|
|
87
|
-
}
|
|
88
|
-
expect(seen.size).toBe(50)
|
|
89
|
-
for (const p of seen) {
|
|
90
|
-
expect(p).not.toBe(a)
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it('gc() cleans orphan .part files after a short grace period', async () => {
|
|
95
|
-
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 10_000, orphanGraceMs: 50 })
|
|
96
|
-
// Simulate a crashed write — leave a .part tmp file behind
|
|
97
|
-
const dir = path.join(tmpRoot, 'feishu', 'sess-1')
|
|
98
|
-
await fs.mkdir(dir, { recursive: true })
|
|
99
|
-
const orphan = path.join(dir, 'image.png.1234.5678.part')
|
|
100
|
-
await fs.writeFile(orphan, 'ORPHAN')
|
|
101
|
-
// Age the orphan so gc considers it stale
|
|
102
|
-
const past = new Date(Date.now() - 1000)
|
|
103
|
-
await fs.utimes(orphan, past, past)
|
|
104
|
-
const result = await store.gc()
|
|
105
|
-
expect(result.removed).toBeGreaterThanOrEqual(1)
|
|
106
|
-
await expect(fs.access(orphan)).rejects.toThrow()
|
|
107
|
-
})
|
|
108
|
-
})
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'bun:test'
|
|
2
|
-
import { ImageBlockWatcher } from '../image-block-watcher.js'
|
|
3
|
-
|
|
4
|
-
describe('ImageBlockWatcher', () => {
|
|
5
|
-
it('extracts a markdown image with http URL', () => {
|
|
6
|
-
const w = new ImageBlockWatcher()
|
|
7
|
-
const out = w.feed('Here is  an image.')
|
|
8
|
-
expect(out.length).toBe(1)
|
|
9
|
-
const source = out[0]!.source
|
|
10
|
-
expect(source.kind).toBe('url')
|
|
11
|
-
if (source.kind === 'url') {
|
|
12
|
-
expect(source.url).toBe('https://example.com/foo.png')
|
|
13
|
-
}
|
|
14
|
-
expect(out[0]!.alt).toBe('alt')
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('extracts a markdown image with absolute local path', () => {
|
|
18
|
-
const w = new ImageBlockWatcher()
|
|
19
|
-
const out = w.feed('')
|
|
20
|
-
expect(out.length).toBe(1)
|
|
21
|
-
const source = out[0]!.source
|
|
22
|
-
expect(source.kind).toBe('path')
|
|
23
|
-
if (source.kind === 'path') {
|
|
24
|
-
expect(source.path).toBe('/tmp/cat.jpg')
|
|
25
|
-
}
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('extracts a markdown image with file:// URL as path', () => {
|
|
29
|
-
const w = new ImageBlockWatcher()
|
|
30
|
-
const out = w.feed('')
|
|
31
|
-
const source = out[0]!.source
|
|
32
|
-
expect(source.kind).toBe('path')
|
|
33
|
-
if (source.kind === 'path') expect(source.path).toBe('/var/img/x.png')
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('extracts a data URI as base64', () => {
|
|
37
|
-
const w = new ImageBlockWatcher()
|
|
38
|
-
const out = w.feed('')
|
|
39
|
-
const source = out[0]!.source
|
|
40
|
-
expect(source.kind).toBe('base64')
|
|
41
|
-
if (source.kind === 'base64') {
|
|
42
|
-
expect(source.mime).toBe('image/png')
|
|
43
|
-
expect(source.data).toBe('AAAA')
|
|
44
|
-
}
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('deduplicates the same image across multiple feeds', () => {
|
|
48
|
-
const w = new ImageBlockWatcher()
|
|
49
|
-
const a = w.feed('')
|
|
50
|
-
const b = w.feed(' repeated  again')
|
|
51
|
-
expect(a.length).toBe(1)
|
|
52
|
-
expect(b.length).toBe(0)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('handles images split across feed boundaries', () => {
|
|
56
|
-
const w = new ImageBlockWatcher()
|
|
57
|
-
const a = w.feed('a  b')
|
|
59
|
-
expect(a.length).toBe(0)
|
|
60
|
-
expect(b.length).toBe(1)
|
|
61
|
-
const source = b[0]!.source
|
|
62
|
-
expect(source.kind).toBe('path')
|
|
63
|
-
if (source.kind === 'path') expect(source.path).toBe('/tmp/x.png')
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('skips non-image markdown links', () => {
|
|
67
|
-
const w = new ImageBlockWatcher()
|
|
68
|
-
const out = w.feed('See [docs](https://example.com).')
|
|
69
|
-
expect(out.length).toBe(0)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('drain() returns all accumulated uploads', () => {
|
|
73
|
-
const w = new ImageBlockWatcher()
|
|
74
|
-
w.feed('')
|
|
75
|
-
w.feed(' and ')
|
|
76
|
-
const all = w.drain()
|
|
77
|
-
expect(all.length).toBe(2)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('reset() clears buffer, seen set, and accumulated list', () => {
|
|
81
|
-
const w = new ImageBlockWatcher()
|
|
82
|
-
w.feed('')
|
|
83
|
-
w.reset()
|
|
84
|
-
// After reset, drain() is empty
|
|
85
|
-
expect(w.drain().length).toBe(0)
|
|
86
|
-
// And re-feeding the same image yields a fresh emit (dedup state cleared)
|
|
87
|
-
const out = w.feed('')
|
|
88
|
-
expect(out.length).toBe(1)
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('skips relative paths (cannot be resolved safely)', () => {
|
|
92
|
-
const w = new ImageBlockWatcher()
|
|
93
|
-
const out = w.feed(' and ')
|
|
94
|
-
expect(out.length).toBe(1)
|
|
95
|
-
const source = out[0]!.source
|
|
96
|
-
expect(source.kind).toBe('path')
|
|
97
|
-
if (source.kind === 'path') expect(source.path).toBe('/tmp/ok.png')
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('extracts multiple images from a single feed chunk in order', () => {
|
|
101
|
-
const w = new ImageBlockWatcher()
|
|
102
|
-
const out = w.feed('  ')
|
|
103
|
-
expect(out.length).toBe(3)
|
|
104
|
-
expect(out[0]!.source.kind).toBe('path')
|
|
105
|
-
expect(out[1]!.source.kind).toBe('url')
|
|
106
|
-
expect(out[2]!.source.kind).toBe('base64')
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it('rejects malformed data URI (not base64)', () => {
|
|
110
|
-
const w = new ImageBlockWatcher()
|
|
111
|
-
const out = w.feed('')
|
|
112
|
-
// Not in `;base64,` form → classify returns null → skipped
|
|
113
|
-
expect(out.length).toBe(0)
|
|
114
|
-
})
|
|
115
|
-
})
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Size and MIME restrictions for IM attachments.
|
|
3
|
-
*
|
|
4
|
-
* Limits chosen to sit safely under both Feishu (10 MB image / 30 MB file)
|
|
5
|
-
* and Telegram Bot API (10 MB image / 50 MB file), and under Claude API's
|
|
6
|
-
* own image size bounds.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export const IMAGE_MAX_BYTES = 10 * 1024 * 1024 // 10 MB
|
|
10
|
-
export const FILE_MAX_BYTES = 30 * 1024 * 1024 // 30 MB
|
|
11
|
-
|
|
12
|
-
export const IMAGE_MIME_WHITELIST = [
|
|
13
|
-
'image/jpeg',
|
|
14
|
-
'image/png',
|
|
15
|
-
'image/gif',
|
|
16
|
-
'image/webp',
|
|
17
|
-
] as const
|
|
18
|
-
|
|
19
|
-
export type LimitCheckResult =
|
|
20
|
-
| { ok: true }
|
|
21
|
-
| { ok: false; reason: 'too_large' | 'unsupported_mime'; hint: string }
|
|
22
|
-
|
|
23
|
-
function formatMb(bytes: number): string {
|
|
24
|
-
return (bytes / (1024 * 1024)).toFixed(1)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function checkAttachmentLimit(
|
|
28
|
-
kind: 'image' | 'file',
|
|
29
|
-
size: number,
|
|
30
|
-
mime?: string,
|
|
31
|
-
): LimitCheckResult {
|
|
32
|
-
if (kind === 'image') {
|
|
33
|
-
if (size > IMAGE_MAX_BYTES) {
|
|
34
|
-
return {
|
|
35
|
-
ok: false,
|
|
36
|
-
reason: 'too_large',
|
|
37
|
-
hint: `📎 图片过大(${formatMb(size)} MB),请控制在 10 MB 以内`,
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
if (mime && !IMAGE_MIME_WHITELIST.includes(mime as (typeof IMAGE_MIME_WHITELIST)[number])) {
|
|
41
|
-
return {
|
|
42
|
-
ok: false,
|
|
43
|
-
reason: 'unsupported_mime',
|
|
44
|
-
hint: `📎 暂不支持此图片格式(${mime})`,
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return { ok: true }
|
|
48
|
-
}
|
|
49
|
-
// kind === 'file'
|
|
50
|
-
if (size > FILE_MAX_BYTES) {
|
|
51
|
-
return {
|
|
52
|
-
ok: false,
|
|
53
|
-
reason: 'too_large',
|
|
54
|
-
hint: `📎 文件过大(${formatMb(size)} MB),请控制在 30 MB 以内`,
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return { ok: true }
|
|
58
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local staging directory for IM-downloaded resources.
|
|
3
|
-
*
|
|
4
|
-
* Layout: {root}/{platform}/{sessionId}/{safeName}
|
|
5
|
-
* Default root: ~/.claude/im-downloads
|
|
6
|
-
*
|
|
7
|
-
* Responsibilities:
|
|
8
|
-
* - Generate unique, safe paths from (platform, sessionId, originalName)
|
|
9
|
-
* - Atomic write (tmp → rename) so concurrent downloads never corrupt each other
|
|
10
|
-
* - GC files that haven't been touched for `retentionMs` (default 24h)
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import * as fs from 'node:fs/promises'
|
|
14
|
-
import * as fsSync from 'node:fs'
|
|
15
|
-
import type { Dirent } from 'node:fs'
|
|
16
|
-
import * as path from 'node:path'
|
|
17
|
-
import * as os from 'node:os'
|
|
18
|
-
import type { ImPlatform } from './attachment-types.js'
|
|
19
|
-
|
|
20
|
-
export interface AttachmentStoreConfig {
|
|
21
|
-
root: string
|
|
22
|
-
retentionMs: number
|
|
23
|
-
/** Grace window before a `.part` orphan (left behind by a crashed writer)
|
|
24
|
-
* is eligible for GC. Default 10 minutes. */
|
|
25
|
-
orphanGraceMs: number
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
29
|
-
const DEFAULT_ORPHAN_GRACE_MS = 10 * 60 * 1000
|
|
30
|
-
|
|
31
|
-
function defaultRoot(): string {
|
|
32
|
-
return path.join(os.homedir(), '.claude', 'im-downloads')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Strip path separators / .. / control chars from a filename. */
|
|
36
|
-
function sanitizeFilename(name: string): string {
|
|
37
|
-
// eslint-disable-next-line no-control-regex
|
|
38
|
-
const base = path.basename(name || '').replace(/[\x00-\x1f]/g, '')
|
|
39
|
-
const cleaned = base.replace(/[\/\\]/g, '_').replace(/\.\.+/g, '_')
|
|
40
|
-
return cleaned.trim() || 'unnamed'
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export class AttachmentStore {
|
|
44
|
-
private readonly root: string
|
|
45
|
-
private readonly retentionMs: number
|
|
46
|
-
private readonly orphanGraceMs: number
|
|
47
|
-
|
|
48
|
-
constructor(config?: Partial<AttachmentStoreConfig>) {
|
|
49
|
-
this.root = config?.root ?? defaultRoot()
|
|
50
|
-
this.retentionMs = config?.retentionMs ?? DEFAULT_RETENTION_MS
|
|
51
|
-
this.orphanGraceMs = config?.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Compute the target path. Creates parent dirs on demand.
|
|
55
|
-
* If a file with the same name already exists, prefix with a timestamp
|
|
56
|
-
* to avoid clobbering. */
|
|
57
|
-
resolvePath(platform: ImPlatform, sessionId: string, name: string): string {
|
|
58
|
-
const safeSession = sanitizeFilename(sessionId)
|
|
59
|
-
const dir = path.join(this.root, platform, safeSession)
|
|
60
|
-
fsSync.mkdirSync(dir, { recursive: true })
|
|
61
|
-
const safeName = sanitizeFilename(name)
|
|
62
|
-
const candidate = path.join(dir, safeName)
|
|
63
|
-
if (!fsSync.existsSync(candidate)) return candidate
|
|
64
|
-
const { name: base, ext } = path.parse(safeName)
|
|
65
|
-
// Collisions are rare in practice, but multiple downloads landing in the
|
|
66
|
-
// same millisecond must still produce unique paths — append a random
|
|
67
|
-
// suffix so the bare timestamp alone never clashes.
|
|
68
|
-
const rand = Math.random().toString(36).slice(2, 8)
|
|
69
|
-
return path.join(dir, `${base}-${Date.now()}-${rand}${ext}`)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Write atomically: stream to {target}.part, then rename. */
|
|
73
|
-
async write(target: string, data: Buffer): Promise<string> {
|
|
74
|
-
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
75
|
-
const tmp = `${target}.${process.pid}.${Date.now()}.part`
|
|
76
|
-
await fs.writeFile(tmp, data)
|
|
77
|
-
await fs.rename(tmp, target)
|
|
78
|
-
return target
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Remove files older than retentionMs. Returns summary. */
|
|
82
|
-
async gc(): Promise<{ removed: number; bytes: number }> {
|
|
83
|
-
let removed = 0
|
|
84
|
-
let bytes = 0
|
|
85
|
-
const now = Date.now()
|
|
86
|
-
|
|
87
|
-
const walk = async (dir: string): Promise<void> => {
|
|
88
|
-
let entries: Dirent<string>[]
|
|
89
|
-
try {
|
|
90
|
-
// Pass encoding explicitly so Dirent stays string-typed under
|
|
91
|
-
// newer @types/node where the Buffer overload becomes the default.
|
|
92
|
-
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' })
|
|
93
|
-
} catch {
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
for (const entry of entries) {
|
|
97
|
-
const full = path.join(dir, entry.name)
|
|
98
|
-
if (entry.isDirectory()) {
|
|
99
|
-
await walk(full)
|
|
100
|
-
} else if (entry.isFile()) {
|
|
101
|
-
try {
|
|
102
|
-
const stat = await fs.stat(full)
|
|
103
|
-
const age = now - stat.mtimeMs
|
|
104
|
-
const isOrphanPart = entry.name.endsWith('.part')
|
|
105
|
-
const threshold = isOrphanPart ? this.orphanGraceMs : this.retentionMs
|
|
106
|
-
if (age > threshold) {
|
|
107
|
-
bytes += stat.size
|
|
108
|
-
await fs.unlink(full)
|
|
109
|
-
removed++
|
|
110
|
-
}
|
|
111
|
-
} catch {
|
|
112
|
-
// ignore races
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
await walk(this.root).catch(() => {})
|
|
119
|
-
return { removed, bytes }
|
|
120
|
-
}
|
|
121
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared attachment types for IM adapters.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { AttachmentRef } from '../ws-bridge.js'
|
|
6
|
-
export type { AttachmentRef }
|
|
7
|
-
|
|
8
|
-
/** Platform tag — used for local staging subdir and telemetry. */
|
|
9
|
-
export type ImPlatform = 'feishu' | 'telegram'
|
|
10
|
-
|
|
11
|
-
/** Result of downloading an IM resource into the local stage dir. */
|
|
12
|
-
export interface LocalAttachment {
|
|
13
|
-
kind: 'image' | 'file'
|
|
14
|
-
name: string // original filename, or synthesized if none
|
|
15
|
-
path: string // absolute path on disk (under ~/.claude/im-downloads)
|
|
16
|
-
size: number // bytes
|
|
17
|
-
mimeType: string // detected or provided
|
|
18
|
-
buffer: Buffer // raw bytes (kept so caller can choose base64 vs path)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Pending outbound media found in Agent stream output. */
|
|
22
|
-
export interface PendingUpload {
|
|
23
|
-
id: string // fingerprint, used for dedup
|
|
24
|
-
source:
|
|
25
|
-
| { kind: 'base64'; data: string; mime: string }
|
|
26
|
-
| { kind: 'path'; path: string; mime?: string }
|
|
27
|
-
| { kind: 'url'; url: string; mime?: string }
|
|
28
|
-
alt?: string
|
|
29
|
-
}
|