@onmars/lunar-core 0.2.0 → 0.4.0
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/package.json +1 -1
- package/src/__tests__/router.test.ts +144 -0
- package/src/lib/router.ts +33 -1
package/package.json
CHANGED
|
@@ -1190,6 +1190,150 @@ describe('Router', () => {
|
|
|
1190
1190
|
})
|
|
1191
1191
|
})
|
|
1192
1192
|
|
|
1193
|
+
// ─── Attachment download and injection ──────────────────────
|
|
1194
|
+
|
|
1195
|
+
describe('image/file attachment handling', () => {
|
|
1196
|
+
it('downloads image attachment and prepends path to prompt', async () => {
|
|
1197
|
+
const spy = createSpyAgent()
|
|
1198
|
+
registry.registerAgent(spy, true)
|
|
1199
|
+
|
|
1200
|
+
const channels: import('../types/moon').ChannelPersona[] = [
|
|
1201
|
+
{ id: 'chan-1', name: 'orbit', platform: 'discord' },
|
|
1202
|
+
]
|
|
1203
|
+
const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
|
|
1204
|
+
|
|
1205
|
+
// Mock global fetch to return fake image data
|
|
1206
|
+
const originalFetch = globalThis.fetch
|
|
1207
|
+
globalThis.fetch = (async () =>
|
|
1208
|
+
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
|
|
1209
|
+
status: 200,
|
|
1210
|
+
})) as unknown as typeof fetch
|
|
1211
|
+
|
|
1212
|
+
try {
|
|
1213
|
+
const message: IncomingMessage = {
|
|
1214
|
+
id: 'msg-1',
|
|
1215
|
+
channelId: 'chan-1',
|
|
1216
|
+
text: 'What is this?',
|
|
1217
|
+
sender: { id: 'user-123', name: 'Test User' },
|
|
1218
|
+
timestamp: new Date(),
|
|
1219
|
+
attachments: [
|
|
1220
|
+
{
|
|
1221
|
+
type: 'image',
|
|
1222
|
+
url: 'https://cdn.discord.com/attachments/test/image.png',
|
|
1223
|
+
filename: 'photo.png',
|
|
1224
|
+
mimeType: 'image/png',
|
|
1225
|
+
},
|
|
1226
|
+
],
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
await router.route('discord', message)
|
|
1230
|
+
|
|
1231
|
+
expect(spy.capturedInput).not.toBeNull()
|
|
1232
|
+
expect(spy.capturedInput.prompt).toContain('[media attached:')
|
|
1233
|
+
expect(spy.capturedInput.prompt).toContain('.png')
|
|
1234
|
+
expect(spy.capturedInput.prompt).toContain('What is this?')
|
|
1235
|
+
} finally {
|
|
1236
|
+
globalThis.fetch = originalFetch
|
|
1237
|
+
}
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
it('handles multiple attachments', async () => {
|
|
1241
|
+
const spy = createSpyAgent()
|
|
1242
|
+
registry.registerAgent(spy, true)
|
|
1243
|
+
|
|
1244
|
+
const channels: import('../types/moon').ChannelPersona[] = [
|
|
1245
|
+
{ id: 'chan-1', name: 'orbit', platform: 'discord' },
|
|
1246
|
+
]
|
|
1247
|
+
const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
|
|
1248
|
+
|
|
1249
|
+
const originalFetch = globalThis.fetch
|
|
1250
|
+
globalThis.fetch = (async () =>
|
|
1251
|
+
new Response(new Uint8Array([0x00, 0x01]), { status: 200 })) as unknown as typeof fetch
|
|
1252
|
+
|
|
1253
|
+
try {
|
|
1254
|
+
const message: IncomingMessage = {
|
|
1255
|
+
id: 'msg-1',
|
|
1256
|
+
channelId: 'chan-1',
|
|
1257
|
+
text: 'Check these',
|
|
1258
|
+
sender: { id: 'user-123', name: 'Test User' },
|
|
1259
|
+
timestamp: new Date(),
|
|
1260
|
+
attachments: [
|
|
1261
|
+
{ type: 'image', url: 'https://example.com/a.png', filename: 'a.png', mimeType: 'image/png' },
|
|
1262
|
+
{ type: 'file', url: 'https://example.com/b.pdf', filename: 'b.pdf', mimeType: 'application/pdf' },
|
|
1263
|
+
],
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
await router.route('discord', message)
|
|
1267
|
+
|
|
1268
|
+
expect(spy.capturedInput.prompt).toContain('.png')
|
|
1269
|
+
expect(spy.capturedInput.prompt).toContain('.pdf')
|
|
1270
|
+
expect(spy.capturedInput.prompt).toContain('Check these')
|
|
1271
|
+
} finally {
|
|
1272
|
+
globalThis.fetch = originalFetch
|
|
1273
|
+
}
|
|
1274
|
+
})
|
|
1275
|
+
|
|
1276
|
+
it('skips audio attachments (handled by STT)', async () => {
|
|
1277
|
+
const spy = createSpyAgent()
|
|
1278
|
+
registry.registerAgent(spy, true)
|
|
1279
|
+
|
|
1280
|
+
const channels: import('../types/moon').ChannelPersona[] = [
|
|
1281
|
+
{ id: 'chan-1', name: 'orbit', platform: 'discord' },
|
|
1282
|
+
]
|
|
1283
|
+
const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
|
|
1284
|
+
|
|
1285
|
+
const message: IncomingMessage = {
|
|
1286
|
+
id: 'msg-1',
|
|
1287
|
+
channelId: 'chan-1',
|
|
1288
|
+
text: 'Hello',
|
|
1289
|
+
sender: { id: 'user-123', name: 'Test User' },
|
|
1290
|
+
timestamp: new Date(),
|
|
1291
|
+
attachments: [
|
|
1292
|
+
{ type: 'audio', url: 'https://example.com/voice.ogg', filename: 'voice.ogg' },
|
|
1293
|
+
],
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
await router.route('discord', message)
|
|
1297
|
+
|
|
1298
|
+
// Audio should not be downloaded by the image handler (no fetch mock needed)
|
|
1299
|
+
expect(spy.capturedInput.prompt).toBe('Hello')
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
it('continues gracefully when attachment download fails', async () => {
|
|
1303
|
+
const spy = createSpyAgent()
|
|
1304
|
+
registry.registerAgent(spy, true)
|
|
1305
|
+
|
|
1306
|
+
const channels: import('../types/moon').ChannelPersona[] = [
|
|
1307
|
+
{ id: 'chan-1', name: 'orbit', platform: 'discord' },
|
|
1308
|
+
]
|
|
1309
|
+
const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
|
|
1310
|
+
|
|
1311
|
+
const originalFetch = globalThis.fetch
|
|
1312
|
+
globalThis.fetch = (async () =>
|
|
1313
|
+
new Response(null, { status: 404 })) as unknown as typeof fetch
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
const message: IncomingMessage = {
|
|
1317
|
+
id: 'msg-1',
|
|
1318
|
+
channelId: 'chan-1',
|
|
1319
|
+
text: 'Check this',
|
|
1320
|
+
sender: { id: 'user-123', name: 'Test User' },
|
|
1321
|
+
timestamp: new Date(),
|
|
1322
|
+
attachments: [
|
|
1323
|
+
{ type: 'image', url: 'https://example.com/broken.png', filename: 'broken.png' },
|
|
1324
|
+
],
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
await router.route('discord', message)
|
|
1328
|
+
|
|
1329
|
+
// Should still send the text without attachment reference
|
|
1330
|
+
expect(spy.capturedInput.prompt).toBe('Check this')
|
|
1331
|
+
} finally {
|
|
1332
|
+
globalThis.fetch = originalFetch
|
|
1333
|
+
}
|
|
1334
|
+
})
|
|
1335
|
+
})
|
|
1336
|
+
|
|
1193
1337
|
// ─── Multi-agent routing (Phase B) ────────────────────────
|
|
1194
1338
|
|
|
1195
1339
|
describe('multi-agent routing — channelPersona.agentId directs to correct agent', () => {
|
package/src/lib/router.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
2
3
|
import { resolve } from 'node:path'
|
|
3
4
|
import type { AgentEvent, AgentInput } from '../types/agent'
|
|
4
5
|
import type { IncomingMessage, OutgoingMessage } from '../types/channel'
|
|
@@ -316,6 +317,37 @@ export class Router {
|
|
|
316
317
|
}
|
|
317
318
|
}
|
|
318
319
|
|
|
320
|
+
// ─── Image/File attachments: download and save ───────────────
|
|
321
|
+
const nonAudioAttachments =
|
|
322
|
+
message.attachments?.filter((a) => a.type !== 'audio') ?? []
|
|
323
|
+
|
|
324
|
+
if (nonAudioAttachments.length > 0) {
|
|
325
|
+
const mediaDir = resolve(this.config.dataDir, 'media', 'inbound')
|
|
326
|
+
mkdirSync(mediaDir, { recursive: true })
|
|
327
|
+
|
|
328
|
+
for (const att of nonAudioAttachments) {
|
|
329
|
+
try {
|
|
330
|
+
const buffer = await this.downloadAttachment(att.url)
|
|
331
|
+
const ext =
|
|
332
|
+
att.filename?.split('.').pop() ?? (att.type === 'image' ? 'png' : 'bin')
|
|
333
|
+
const filename = `${randomUUID()}.${ext}`
|
|
334
|
+
const filepath = resolve(mediaDir, filename)
|
|
335
|
+
writeFileSync(filepath, buffer)
|
|
336
|
+
|
|
337
|
+
const label = att.type === 'image' ? 'image' : att.type
|
|
338
|
+
effectiveText = `[media attached: ${filepath} (${att.mimeType ?? label}) | ${filepath}]\n${effectiveText}`
|
|
339
|
+
|
|
340
|
+
log.info(
|
|
341
|
+
{ type: att.type, filename: att.filename, size: buffer.length, path: filepath },
|
|
342
|
+
'Attachment saved',
|
|
343
|
+
)
|
|
344
|
+
} catch (err) {
|
|
345
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
346
|
+
log.warn({ error: errMsg, url: att.url }, 'Failed to download attachment — skipping')
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
319
351
|
// Build system prompt from moon + channel persona
|
|
320
352
|
let systemPrompt = moon ? this.moonLoader.buildSystemPrompt(moon, channelPersona) : undefined
|
|
321
353
|
|