diff --git a/src/inbound.ts b/src/inbound.ts index 75c5f4d..c542a7f 100644 --- a/src/inbound.ts +++ b/src/inbound.ts @@ -1,3 +1,6 @@ +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { io, type Socket } from 'socket.io-client'; import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch'; import type { FabricClient, FabricSession } from './fabric-client.js'; @@ -20,6 +23,7 @@ type Core = { type Logger = { info: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void }; +type FabricAttachment = { url: string; name?: string; mimeType?: string }; type FabricMessage = { messageId: string; seq: number; @@ -27,6 +31,7 @@ type FabricMessage = { authorUserId?: string; createdAt?: string; channelId?: string; + attachments?: FabricAttachment[]; wakeup?: boolean; }; @@ -109,6 +114,53 @@ export class FabricInbound { } } + // Download a message's attachments to a temp dir using the agent's guild + // token; returns local paths/types/urls for the inbound media context. + private async fetchAttachments( + agentId: string, + endpoint: string, + token: string | undefined, + m: FabricMessage, + ): Promise<{ paths: string[]; types: string[]; urls: string[] }> { + const out = { paths: [] as string[], types: [] as string[], urls: [] as string[] }; + const list = m.attachments ?? []; + if (!list.length || !token) return out; + const dir = join(tmpdir(), `fabric-media-${agentId}-${m.messageId}`.replace(/[^\w.-]/g, '_')); + try { + await fs.mkdir(dir, { recursive: true }); + } catch { + return out; + } + let i = 0; + for (const a of list) { + try { + const abs = a.url.startsWith('http') ? a.url : `${endpoint}${a.url}`; + const res = await fetch(abs, { headers: { authorization: `Bearer ${token}` } }); + if (!res.ok) { + this.log.warn(`fabric: attachment fetch ${res.status} ${abs}`); + continue; + } + const buf = Buffer.from(await res.arrayBuffer()); + const safe = (a.name ?? `file-${i}`).replace(/[^\w.-]/g, '_').slice(0, 120) || `file-${i}`; + const p = join(dir, `${i}-${safe}`); + await fs.writeFile(p, buf); + out.paths.push(p); + out.types.push( + a.mimeType || + res.headers.get('content-type')?.split(';')[0] || + 'application/octet-stream', + ); + out.urls.push(abs); + i++; + } catch (err) { + this.log.warn(`fabric: attachment fetch failed agent=${agentId}: ${String(err)}`); + } + } + if (out.paths.length) + this.log.info(`fabric: fetched ${out.paths.length} attachment(s) agent=${agentId}`); + return out; + } + private async dispatch( agentId: string, guild: { nodeId: string; endpoint: string }, @@ -135,6 +187,13 @@ export class FabricInbound { const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, }); + + const gt = session.guildAccessTokens.find((t) => t.guildNodeId === guild.nodeId)?.token; + + // Fetch any uploaded files for the agent: download to a temp dir and + // hand openclaw local MediaPaths (+types) so the model receives them. + const media = await this.fetchAttachments(agentId, guild.endpoint, gt, m); + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: m.content, BodyForAgent: m.content, @@ -153,10 +212,18 @@ export class FabricInbound { Timestamp: m.createdAt ? Date.parse(m.createdAt) : Date.now(), OriginatingChannel: 'fabric', OriginatingTo: `fabric:${channelId}`, + ...(media.paths.length + ? { + MediaPaths: media.paths, + MediaTypes: media.types, + MediaUrls: media.urls, + MediaPath: media.paths[0], + MediaType: media.types[0], + MediaUrl: media.urls[0], + } + : {}), }); - const gt = session.guildAccessTokens.find((t) => t.guildNodeId === guild.nodeId)?.token; - await dispatchInboundReplyWithBase({ cfg: this.cfg as never, channel: 'fabric',