@onmars/lunar-core 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onmars/lunar-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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