@efengx/openclaw-channel-dragon 0.5.35 → 0.5.37

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.
@@ -1,5 +1,6 @@
1
1
  import { IComponent } from "../../core/IComponent.js";
2
2
  import { TelemetryComponent } from "../telemetry/TelemetryComponent.js";
3
+ import { MediaRegistryComponent } from "../media/MediaRegistryComponent.js";
3
4
  export interface ChannelOptions {
4
5
  accountId: string;
5
6
  agentId: string;
@@ -10,9 +11,10 @@ export interface ChannelOptions {
10
11
  export declare class ChannelComponent implements IComponent {
11
12
  private options;
12
13
  private telemetry;
14
+ private mediaRegistry;
13
15
  private processedMessageIds;
14
16
  private sessionQueues;
15
- constructor(options: ChannelOptions, telemetry: TelemetryComponent);
17
+ constructor(options: ChannelOptions, telemetry: TelemetryComponent, mediaRegistry: MediaRegistryComponent);
16
18
  start(): Promise<void>;
17
19
  stop(): Promise<void>;
18
20
  private normalizeSessionSegment;
@@ -23,16 +25,17 @@ export declare class ChannelComponent implements IComponent {
23
25
  private resolveWorkbenchSessionIdFromOpenClawSessionKey;
24
26
  private stringifyProgressDetail;
25
27
  private findTaskCompletionEvent;
26
- private extractGeneratedMediaLines;
27
28
  private formatTaskCompletionReply;
28
29
  deliverToOpenClaw: (content: string, sessionId?: string, modelId?: string, attachments?: any[], messageId?: string | number) => Promise<void>;
29
30
  handleOutboundText: (ctx: any) => Promise<{
30
31
  ok: boolean;
31
32
  messageId: string;
32
33
  error: any;
34
+ attachments?: undefined;
33
35
  } | {
34
36
  ok: boolean;
35
37
  messageId: string;
38
+ attachments: import("../media/MediaRegistryComponent.js").DragonMediaAttachment[];
36
39
  error?: undefined;
37
40
  }>;
38
41
  reportTargetProtocolError: (message: string, rawTarget: unknown) => Promise<void>;
@@ -4,11 +4,13 @@ const workbenchTargetPrefix = "dragon-workbench:";
4
4
  export class ChannelComponent {
5
5
  options;
6
6
  telemetry;
7
+ mediaRegistry;
7
8
  processedMessageIds = new Set();
8
9
  sessionQueues = new Map();
9
- constructor(options, telemetry) {
10
+ constructor(options, telemetry, mediaRegistry) {
10
11
  this.options = options;
11
12
  this.telemetry = telemetry;
13
+ this.mediaRegistry = mediaRegistry;
12
14
  }
13
15
  async start() { }
14
16
  async stop() { }
@@ -96,29 +98,8 @@ export class ChannelComponent {
96
98
  }
97
99
  return undefined;
98
100
  }
99
- extractGeneratedMediaLines(event) {
100
- const lines = new Set();
101
- for (const url of Array.isArray(event?.mediaUrls) ? event.mediaUrls : []) {
102
- if (typeof url === 'string' && url.trim()) {
103
- lines.add(`MEDIA:${url.trim()}`);
104
- }
105
- }
106
- for (const attachment of Array.isArray(event?.attachments) ? event.attachments : []) {
107
- const value = attachment?.url || attachment?.path || attachment?.filePath || attachment?.mediaUrl;
108
- if (typeof value === 'string' && value.trim()) {
109
- lines.add(`MEDIA:${value.trim()}`);
110
- }
111
- }
112
- return Array.from(lines);
113
- }
114
101
  formatTaskCompletionReply(event) {
115
- const result = typeof event?.result === 'string' ? event.result.trim() : '';
116
- const mediaLines = this.extractGeneratedMediaLines(event);
117
- const lines = [
118
- result,
119
- ...mediaLines.filter((line) => !result.includes(line)),
120
- ].filter((line) => line.trim());
121
- return lines.join('\n');
102
+ return typeof event?.result === 'string' ? event.result.trim() : '';
122
103
  }
123
104
  deliverToOpenClaw = async (content, sessionId = 'default', modelId, attachments, messageId) => {
124
105
  const replyMsgId = messageId ? `dragon_msg_${messageId}` : `dragon_msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -166,13 +147,15 @@ export class ChannelComponent {
166
147
  dispatcherOptions: {
167
148
  deliver: async (payload) => {
168
149
  const text = payload?.text || "";
169
- if (!text && !payload?.tool_calls?.length)
150
+ const archivedAttachments = await this.mediaRegistry.archiveStructuredMedia(payload);
151
+ if (!text && !payload?.tool_calls?.length && archivedAttachments.length === 0)
170
152
  return;
171
153
  await this.telemetry.reportReply({
172
154
  content: text,
173
155
  sessionId,
174
156
  tool_calls: payload?.tool_calls,
175
157
  reasoning_content: payload?.reasoning_content,
158
+ attachments: archivedAttachments.length > 0 ? archivedAttachments : payload?.attachments,
176
159
  source: "telemetry_deliver",
177
160
  msgId: replyMsgId,
178
161
  });
@@ -218,12 +201,16 @@ export class ChannelComponent {
218
201
  });
219
202
  },
220
203
  onToolResult: async (payload) => {
204
+ const archivedAttachments = await this.mediaRegistry.archiveStructuredMedia(payload);
221
205
  await progress({
222
- kind: 'tool',
206
+ kind: archivedAttachments.length > 0 ? 'media' : 'tool',
223
207
  phase: 'end',
224
208
  status: 'completed',
225
- title: '工具执行完成',
226
- detail: this.stringifyProgressDetail(payload?.text || payload),
209
+ title: archivedAttachments.length > 0 ? '媒体文件已生成' : '工具执行完成',
210
+ detail: archivedAttachments.length > 0
211
+ ? archivedAttachments.map((item) => item.name).join(', ')
212
+ : this.stringifyProgressDetail(payload?.text || payload),
213
+ data: archivedAttachments.length > 0 ? { ...payload, attachments: archivedAttachments } : payload,
227
214
  });
228
215
  },
229
216
  onItemEvent: async (payload) => {
@@ -371,6 +358,7 @@ export class ChannelComponent {
371
358
  handleOutboundText = async (ctx) => {
372
359
  const text = ctx?.text || "";
373
360
  const { logger } = this.options;
361
+ const archivedAttachments = await this.mediaRegistry.archiveStructuredMedia(ctx);
374
362
  let sessionId;
375
363
  try {
376
364
  sessionId = this.decodeWorkbenchTarget(ctx?.peer?.id);
@@ -382,8 +370,13 @@ export class ChannelComponent {
382
370
  return { ok: false, messageId: Date.now().toString(), error: message };
383
371
  }
384
372
  logger?.info?.(`[dragon channels][Dragon Plugin] Outbound Text (Action): "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}" [Session: ${sessionId}] [RawPeer: ${ctx?.peer?.id || 'none'}]`);
385
- await this.telemetry.reportReply({ content: text, sessionId, source: "channel_outbound" });
386
- return { ok: true, messageId: Date.now().toString() };
373
+ await this.telemetry.reportReply({
374
+ content: text,
375
+ sessionId,
376
+ attachments: archivedAttachments.length > 0 ? archivedAttachments : ctx?.attachments,
377
+ source: "channel_outbound",
378
+ });
379
+ return { ok: true, messageId: Date.now().toString(), attachments: archivedAttachments };
387
380
  };
388
381
  reportTargetProtocolError = async (message, rawTarget) => {
389
382
  const content = [
@@ -407,7 +400,8 @@ export class ChannelComponent {
407
400
  if (!taskCompletion)
408
401
  return;
409
402
  const content = this.formatTaskCompletionReply(taskCompletion);
410
- if (!content)
403
+ const archivedAttachments = await this.mediaRegistry.archiveStructuredMedia(taskCompletion);
404
+ if (!content && archivedAttachments.length === 0)
411
405
  return;
412
406
  await this.telemetry.reportProgress({
413
407
  sessionId,
@@ -417,11 +411,12 @@ export class ChannelComponent {
417
411
  status: taskCompletion.status === 'ok' ? 'completed' : taskCompletion.status,
418
412
  title: taskCompletion.status === 'ok' ? '后台任务完成' : '后台任务更新',
419
413
  detail: this.stringifyProgressDetail(taskCompletion.taskLabel || taskCompletion.statusLabel),
420
- data: taskCompletion,
414
+ data: archivedAttachments.length > 0 ? { ...taskCompletion, attachments: archivedAttachments } : taskCompletion,
421
415
  });
422
416
  await this.telemetry.reportReply({
423
417
  content,
424
418
  sessionId,
419
+ attachments: archivedAttachments.length > 0 ? archivedAttachments : taskCompletion?.attachments,
425
420
  source: 'agent_event_task_completion',
426
421
  msgId: taskCompletion.childSessionId ? `dragon_task_reply_${taskCompletion.childSessionId}` : undefined,
427
422
  });
@@ -0,0 +1,35 @@
1
+ import { IComponent } from "../../core/IComponent.js";
2
+ type LoggerLike = {
3
+ info?: (...args: any[]) => void;
4
+ warn?: (...args: any[]) => void;
5
+ error?: (...args: any[]) => void;
6
+ };
7
+ export type DragonMediaAttachment = {
8
+ type: string;
9
+ kind: string;
10
+ name: string;
11
+ mimeType?: string;
12
+ sourcePath?: string;
13
+ workspacePath?: string;
14
+ path?: string;
15
+ url?: string;
16
+ size?: number;
17
+ };
18
+ export declare class MediaRegistryComponent implements IComponent {
19
+ private options;
20
+ constructor(options: {
21
+ cfg: any;
22
+ logger?: LoggerLike;
23
+ });
24
+ start(): Promise<void>;
25
+ stop(): Promise<void>;
26
+ private resolveWorkspaceRoot;
27
+ private normalizeLocalPath;
28
+ private isPathInside;
29
+ private inferMime;
30
+ private inferKind;
31
+ private buildWorkspaceRelativePath;
32
+ private collectStructuredMedia;
33
+ archiveStructuredMedia(value: any): Promise<DragonMediaAttachment[]>;
34
+ }
35
+ export {};
@@ -0,0 +1,141 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ export class MediaRegistryComponent {
4
+ options;
5
+ constructor(options) {
6
+ this.options = options;
7
+ }
8
+ async start() { }
9
+ async stop() { }
10
+ resolveWorkspaceRoot() {
11
+ const candidates = [
12
+ this.options.cfg?.agents?.defaults?.workspace,
13
+ this.options.cfg?.agents?.default?.workspace,
14
+ ...(Array.isArray(this.options.cfg?.agents?.list)
15
+ ? this.options.cfg.agents.list.map((agent) => agent?.workspace)
16
+ : []),
17
+ process.env.OPENCLAW_WORKSPACE_ROOT,
18
+ process.env.DRAGON_OPENCLAW_WORKSPACE_ROOT,
19
+ ];
20
+ const found = candidates.find((value) => typeof value === "string" && value.trim());
21
+ return found ? path.resolve(String(found).trim()) : undefined;
22
+ }
23
+ normalizeLocalPath(value) {
24
+ if (typeof value !== "string")
25
+ return undefined;
26
+ const raw = value.trim();
27
+ if (!raw)
28
+ return undefined;
29
+ if (raw.startsWith("file://")) {
30
+ try {
31
+ return path.resolve(decodeURIComponent(new URL(raw).pathname));
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ if (!path.isAbsolute(raw))
38
+ return undefined;
39
+ return path.resolve(raw);
40
+ }
41
+ isPathInside(parent, child) {
42
+ const rel = path.relative(parent, child);
43
+ return rel === "" || (!!rel && !rel.startsWith("..") && !path.isAbsolute(rel));
44
+ }
45
+ inferMime(filePath, fallback) {
46
+ if (fallback)
47
+ return fallback;
48
+ const ext = path.extname(filePath).toLowerCase();
49
+ if (ext === ".png")
50
+ return "image/png";
51
+ if (ext === ".jpg" || ext === ".jpeg")
52
+ return "image/jpeg";
53
+ if (ext === ".webp")
54
+ return "image/webp";
55
+ if (ext === ".gif")
56
+ return "image/gif";
57
+ if (ext === ".svg")
58
+ return "image/svg+xml";
59
+ return undefined;
60
+ }
61
+ inferKind(mimeType) {
62
+ if (mimeType?.startsWith("image/"))
63
+ return "image";
64
+ if (mimeType?.startsWith("video/"))
65
+ return "video";
66
+ if (mimeType?.startsWith("audio/"))
67
+ return "audio";
68
+ return "file";
69
+ }
70
+ buildWorkspaceRelativePath(sourcePath, workspaceRoot) {
71
+ const normalized = path.resolve(sourcePath);
72
+ if (this.isPathInside(workspaceRoot, normalized)) {
73
+ return path.relative(workspaceRoot, normalized).split(path.sep).join("/");
74
+ }
75
+ const marker = `${path.sep}media${path.sep}`;
76
+ const mediaIndex = normalized.lastIndexOf(marker);
77
+ if (mediaIndex >= 0) {
78
+ return path
79
+ .join("media", normalized.slice(mediaIndex + marker.length))
80
+ .split(path.sep)
81
+ .join("/");
82
+ }
83
+ return path.join("media", path.basename(normalized)).split(path.sep).join("/");
84
+ }
85
+ collectStructuredMedia(value) {
86
+ const items = [];
87
+ if (!value || typeof value !== "object")
88
+ return items;
89
+ if (Array.isArray(value.attachments))
90
+ items.push(...value.attachments);
91
+ if (Array.isArray(value.media))
92
+ items.push(...value.media);
93
+ if (Array.isArray(value.mediaUrls)) {
94
+ items.push(...value.mediaUrls.map((url) => ({ url })));
95
+ }
96
+ return items.filter(Boolean);
97
+ }
98
+ async archiveStructuredMedia(value) {
99
+ const workspaceRoot = this.resolveWorkspaceRoot();
100
+ if (!workspaceRoot)
101
+ return [];
102
+ const media = this.collectStructuredMedia(value);
103
+ const seen = new Set();
104
+ const attachments = [];
105
+ for (const item of media) {
106
+ const sourcePath = this.normalizeLocalPath(item?.path ?? item?.filePath ?? item?.mediaPath ?? item?.url);
107
+ if (!sourcePath || seen.has(sourcePath))
108
+ continue;
109
+ seen.add(sourcePath);
110
+ try {
111
+ const stat = await fs.stat(sourcePath);
112
+ if (!stat.isFile())
113
+ continue;
114
+ const workspacePath = this.buildWorkspaceRelativePath(sourcePath, workspaceRoot);
115
+ const targetPath = path.resolve(workspaceRoot, workspacePath);
116
+ if (!this.isPathInside(workspaceRoot, targetPath))
117
+ continue;
118
+ if (path.resolve(sourcePath) !== targetPath) {
119
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
120
+ await fs.copyFile(sourcePath, targetPath);
121
+ }
122
+ const mimeType = this.inferMime(targetPath, item?.mimeType || item?.mime);
123
+ const kind = item?.kind || item?.type || this.inferKind(mimeType);
124
+ attachments.push({
125
+ type: kind,
126
+ kind,
127
+ name: item?.name || path.basename(targetPath),
128
+ mimeType,
129
+ sourcePath,
130
+ workspacePath,
131
+ path: workspacePath,
132
+ size: stat.size,
133
+ });
134
+ }
135
+ catch (error) {
136
+ this.options.logger?.warn?.(`[dragon channels][media] failed to archive media: source=${sourcePath}, error=${error?.message || error}`);
137
+ }
138
+ }
139
+ return attachments;
140
+ }
141
+ }
@@ -23,6 +23,7 @@ export declare class SseComponent implements IComponent {
23
23
  reconnectMs?: number;
24
24
  idleTimeoutMs?: number;
25
25
  });
26
+ private logContext;
26
27
  start(): Promise<void>;
27
28
  stop(): Promise<void>;
28
29
  private connect;
@@ -15,15 +15,18 @@ export class SseComponent {
15
15
  this.channel = channel;
16
16
  this.options = options;
17
17
  }
18
+ logContext() {
19
+ return `version=${this.options.version}, channelAgent=${this.options.agentId}, account=${this.options.accountId}`;
20
+ }
18
21
  async start() {
19
- this.options.logger?.info?.(`[dragon channels][SSE] component start: version=${this.options.version}, agent=${this.options.agentId}, account=${this.options.accountId}, baseUrl=${this.http.options.baseURL}`);
22
+ this.options.logger?.info?.(`[dragon channels][SSE] component start: ${this.logContext()}, baseUrl=${this.http.options.baseURL}`);
20
23
  this.options.setStatus?.({
21
24
  accountId: this.options.accountId,
22
25
  connected: false,
23
26
  lastError: null,
24
27
  });
25
28
  this.options.abortSignal?.addEventListener('abort', () => {
26
- this.options.logger?.info?.(`[dragon channels][SSE] upstream abort received: agent=${this.options.agentId}, account=${this.options.accountId}`);
29
+ this.options.logger?.info?.(`[dragon channels][SSE] upstream abort received: ${this.logContext()}`);
27
30
  void this.stop();
28
31
  }, { once: true });
29
32
  this.connect();
@@ -50,7 +53,7 @@ export class SseComponent {
50
53
  connected: false,
51
54
  lastStopAt: Date.now(),
52
55
  });
53
- this.options.logger?.info?.(`[dragon channels][SSE] component stopped: agent=${this.options.agentId}, account=${this.options.accountId}`);
56
+ this.options.logger?.info?.(`[dragon channels][SSE] component stopped: ${this.logContext()}`);
54
57
  }
55
58
  connect() {
56
59
  if (!this.shouldReconnect)
@@ -64,12 +67,12 @@ export class SseComponent {
64
67
  });
65
68
  const url = `${this.http.options.baseURL}/api/agents/events?${params.toString()}`;
66
69
  const connectionId = `${Date.now()}-${++this.connectionSeq}`;
67
- logger?.info?.(`[dragon channels][SSE] connect start: connection=${connectionId}, attempt=${this.reconnectAttempts + 1}, version=${this.options.version}, url=${url}, agent=${agentId}, account=${this.options.accountId}`);
70
+ logger?.info?.(`[dragon channels][SSE] connect start: connection=${connectionId}, attempt=${this.reconnectAttempts + 1}, ${this.logContext()}, url=${url}`);
68
71
  try {
69
72
  void this.startSseReader(url, connectionId);
70
73
  }
71
74
  catch (err) {
72
- logger?.error?.(`[dragon channels][SSE] connect init failed: connection=${connectionId}, error=${err.message}`);
75
+ logger?.error?.(`[dragon channels][SSE] connect init failed: connection=${connectionId}, ${this.logContext()}, error=${err.message}`);
73
76
  this.markDisconnected(err?.message || String(err));
74
77
  this.scheduleReconnect('init_failed');
75
78
  }
@@ -84,7 +87,7 @@ export class SseComponent {
84
87
  lastTransportActivityAt: now,
85
88
  lastError: null,
86
89
  });
87
- this.options.logger?.debug?.(`[dragon channels][SSE] transport activity: connection=${connectionId}, reason=${reason}, at=${now}`);
90
+ this.options.logger?.debug?.(`[dragon channels][SSE] transport activity: connection=${connectionId}, ${this.logContext()}, reason=${reason}, at=${now}`);
88
91
  this.armIdleWatchdog(connectionId);
89
92
  }
90
93
  armIdleWatchdog(connectionId) {
@@ -95,7 +98,7 @@ export class SseComponent {
95
98
  if (!this.shouldReconnect)
96
99
  return;
97
100
  const idleMs = Date.now() - this.lastTransportActivityAt;
98
- this.options.logger?.warn?.(`[dragon channels][SSE] idle timeout: connection=${connectionId}, idleMs=${idleMs}, timeoutMs=${timeoutMs}; aborting fetch for reconnect`);
101
+ this.options.logger?.warn?.(`[dragon channels][SSE] idle timeout: connection=${connectionId}, ${this.logContext()}, idleMs=${idleMs}, timeoutMs=${timeoutMs}; aborting fetch for reconnect`);
99
102
  this.markDisconnected(`SSE idle timeout after ${idleMs}ms`);
100
103
  this.fetchAbort?.abort();
101
104
  }, timeoutMs);
@@ -142,14 +145,14 @@ export class SseComponent {
142
145
  lastError: null,
143
146
  reconnectAttempts: 0,
144
147
  });
145
- this.options.logger?.info?.(`[dragon channels][SSE] stream established: connection=${connectionId}, agent=${this.options.agentId}, account=${this.options.accountId}`);
148
+ this.options.logger?.info?.(`[dragon channels][SSE] stream established: connection=${connectionId}, ${this.logContext()}`);
146
149
  this.armIdleWatchdog(connectionId);
147
150
  const decoder = new TextDecoder();
148
151
  let buffer = "";
149
152
  while (this.shouldReconnect) {
150
153
  const { value, done } = await reader.read();
151
154
  if (done) {
152
- this.options.logger?.warn?.(`[dragon channels][SSE] stream ended by remote: connection=${connectionId}`);
155
+ this.options.logger?.warn?.(`[dragon channels][SSE] stream ended by remote: connection=${connectionId}, ${this.logContext()}`);
153
156
  break;
154
157
  }
155
158
  this.touchTransport(connectionId, 'chunk');
@@ -165,13 +168,13 @@ export class SseComponent {
165
168
  if (cleanLine.startsWith("data: ")) {
166
169
  try {
167
170
  const data = JSON.parse(cleanLine.slice(6));
168
- this.options.logger?.debug?.(`[dragon channels][SSE] event received: connection=${connectionId}, type=${data?.type || 'unknown'}, agent=${data?.agentId || 'unknown'}`);
171
+ this.options.logger?.debug?.(`[dragon channels][SSE] event received: connection=${connectionId}, ${this.logContext()}, type=${data?.type || 'unknown'}, eventAgent=${data?.agentId || 'unknown'}`);
169
172
  void this.handleEvent(data).catch((err) => {
170
- this.options.logger?.error?.(`[dragon channels][SSE] event handling failed: connection=${connectionId}, error=${err?.message || err}`);
173
+ this.options.logger?.error?.(`[dragon channels][SSE] event handling failed: connection=${connectionId}, ${this.logContext()}, error=${err?.message || err}`);
171
174
  });
172
175
  }
173
176
  catch (e) {
174
- this.options.logger?.error?.(`[dragon channels][SSE] event JSON parse failed: connection=${connectionId}, line="${cleanLine}", error=${e?.message || e}`);
177
+ this.options.logger?.error?.(`[dragon channels][SSE] event JSON parse failed: connection=${connectionId}, ${this.logContext()}, line="${cleanLine}", error=${e?.message || e}`);
175
178
  }
176
179
  }
177
180
  }
@@ -187,7 +190,7 @@ export class SseComponent {
187
190
  const message = err?.name === 'AbortError'
188
191
  ? 'SSE fetch aborted'
189
192
  : (err?.message || String(err));
190
- this.options.logger?.warn?.(`[dragon channels][SSE] stream disconnected: connection=${connectionId}, error=${message}`);
193
+ this.options.logger?.warn?.(`[dragon channels][SSE] stream disconnected: connection=${connectionId}, ${this.logContext()}, error=${message}`);
191
194
  this.markDisconnected(message);
192
195
  this.scheduleReconnect(message);
193
196
  }
@@ -220,12 +223,12 @@ export class SseComponent {
220
223
  // 2. Handle specific types
221
224
  if (data.type === 'WORKBENCH_MESSAGE') {
222
225
  const { content, sessionId, attachments, id, msgId } = data.payload || {};
223
- logger?.info?.(`[dragon channels][Dragon Plugin] v${this.options.version} [SSE] Received from Workbench: "${content?.substring(0, 50)}${content?.length > 50 ? '...' : ''}" [Session: ${sessionId}]`);
226
+ logger?.info?.(`[dragon channels][Dragon Plugin] [SSE] Received from Workbench: ${this.logContext()}, messageAgent=${data?.agentId || 'unknown'}, "${content?.substring(0, 50)}${content?.length > 50 ? '...' : ''}" [Session: ${sessionId}]`);
224
227
  await this.channel.deliverToOpenClaw(content, sessionId, undefined, attachments, msgId || id);
225
228
  }
226
229
  else if (data.type === 'FETCH_HISTORY') {
227
230
  const { sessionId } = data.payload || {};
228
- logger?.info?.(`[dragon channels][Dragon Plugin] Triggering history sync for session: ${sessionId}`);
231
+ logger?.info?.(`[dragon channels][Dragon Plugin] [SSE] Triggering history sync: ${this.logContext()}, session=${sessionId}`);
229
232
  // Handle history sync if needed, but deliverToOpenClaw usually handles normal chat
230
233
  }
231
234
  }
@@ -244,7 +247,7 @@ export class SseComponent {
244
247
  reconnectAttempts: this.reconnectAttempts,
245
248
  lastError: reason,
246
249
  });
247
- this.options.logger?.info?.(`[dragon channels][SSE] reconnect scheduled: attempt=${this.reconnectAttempts}, delayMs=${delayMs}, reason=${reason}, agent=${this.options.agentId}, account=${this.options.accountId}`);
250
+ this.options.logger?.info?.(`[dragon channels][SSE] reconnect scheduled: attempt=${this.reconnectAttempts}, delayMs=${delayMs}, reason=${reason}, ${this.logContext()}`);
248
251
  this.reconnectTimer = setTimeout(() => {
249
252
  this.reconnectTimer = undefined;
250
253
  this.options.setStatus?.({
@@ -13,6 +13,7 @@ export declare class TelemetryComponent implements IComponent {
13
13
  sessionId: string;
14
14
  tool_calls?: any[];
15
15
  reasoning_content?: string;
16
+ attachments?: any[];
16
17
  source?: string;
17
18
  msgId?: string;
18
19
  }): Promise<void>;
@@ -15,7 +15,7 @@ export class TelemetryComponent {
15
15
  const sessionId = payload.sessionId || "default";
16
16
  const content = payload.content || "";
17
17
  const normalized = content.replace(/\s+/g, "");
18
- if (normalized) {
18
+ if (normalized && !payload.attachments?.length) {
19
19
  const key = `${source}_${sessionId}`;
20
20
  const last = this.lastReplies.get(key);
21
21
  const now = Date.now();
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { TelemetryComponent } from "./components/telemetry/TelemetryComponent.js
6
6
  import { HttpComponent } from "./components/http/HttpComponent.js";
7
7
  import { ChannelComponent } from "./components/channel/ChannelComponent.js";
8
8
  import { SseComponent } from "./components/sync/SseComponent.js";
9
+ import { MediaRegistryComponent } from "./components/media/MediaRegistryComponent.js";
9
10
  import { dragonChannelPluginVersion } from "./version.js";
10
11
  const channelId = "dragon";
11
12
  const workbenchTargetPrefix = "dragon-workbench:";
@@ -62,6 +63,10 @@ async function getOrCreateContainer(account, ctx) {
62
63
  logger
63
64
  }));
64
65
  const telemetry = container.register('telemetry', new TelemetryComponent(http, account.agentId));
66
+ const mediaRegistry = container.register('mediaRegistry', new MediaRegistryComponent({
67
+ cfg: ctx.cfg,
68
+ logger,
69
+ }));
65
70
  // 2. Channel Logic (Centralized)
66
71
  const channel = container.register('channel', new ChannelComponent({
67
72
  accountId: account.accountId,
@@ -69,7 +74,7 @@ async function getOrCreateContainer(account, ctx) {
69
74
  channelRuntime: ctx.channelRuntime,
70
75
  cfg: ctx.cfg,
71
76
  logger
72
- }, telemetry));
77
+ }, telemetry, mediaRegistry));
73
78
  // 3. Sync Infrastructure
74
79
  container.register('sse', new SseComponent(http, channel, {
75
80
  agentId: account.agentId,
@@ -248,54 +253,21 @@ const plugin = createChatChannelPlugin({
248
253
  };
249
254
  }
250
255
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction target resolved: sessionId=${sessionId}, target=${target}, rawTarget=${rawTarget}`);
251
- // Collect ALL media URLs from the params (mirrors what OpenClaw tracks internally
252
- // via collectMessagingMediaUrlsFromRecord when the tool call starts, so that
253
- // hasGatewayAgentDeliveredExpectedMedia can match them against expectedMediaUrls).
254
- const sentMediaUrls = [];
255
- const pushMedia = (v, source) => {
256
- if (typeof v === 'string' && v.trim()) {
257
- const url = v.trim();
258
- sentMediaUrls.push(url);
259
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction extracted media URL from ${source}: ${url}`);
260
- }
261
- };
262
- pushMedia(params.media, "params.media");
263
- pushMedia(params.mediaUrl, "params.mediaUrl");
264
- pushMedia(params.path, "params.path");
265
- pushMedia(params.filePath, "params.filePath");
266
- pushMedia(params.fileUrl, "params.fileUrl");
267
- if (Array.isArray(params.mediaUrls)) {
268
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction found params.mediaUrls array, size=${params.mediaUrls.length}`);
269
- for (const u of params.mediaUrls)
270
- pushMedia(u, "params.mediaUrls");
271
- }
272
- if (Array.isArray(params.attachments)) {
273
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction found params.attachments array, size=${params.attachments.length}`);
274
- for (const a of params.attachments) {
275
- if (a && typeof a === 'object') {
276
- pushMedia(a.media, "attachment.media");
277
- pushMedia(a.mediaUrl, "attachment.mediaUrl");
278
- pushMedia(a.path, "attachment.path");
279
- pushMedia(a.filePath, "attachment.filePath");
280
- pushMedia(a.fileUrl, "attachment.fileUrl");
281
- }
282
- }
283
- }
284
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction total media URLs extracted: count=${sentMediaUrls.length}, urls=${JSON.stringify(sentMediaUrls)}`);
285
256
  let text = params.message || params.text || "";
286
257
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction base message text length: ${text.length}`);
287
- // Append media path to text so the Workbench can render the image
288
- for (const url of sentMediaUrls) {
289
- const mediaLine = `MEDIA: ${url}`;
290
- if (!text.includes(mediaLine)) {
291
- text = text ? `${text}\n${mediaLine}` : mediaLine;
292
- }
293
- }
294
258
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction sending text over channel: text_len=${text.length}, targetSession=${sessionId}`);
295
259
  const result = await channel.handleOutboundText({
296
260
  text,
297
- peer: { id: target }
261
+ peer: { id: target },
262
+ attachments: params.attachments,
263
+ media: params.media,
264
+ mediaUrls: params.mediaUrls,
298
265
  });
266
+ const deliveredMediaUrls = Array.isArray(result.attachments)
267
+ ? result.attachments
268
+ .map((attachment) => attachment?.sourcePath || attachment?.path || attachment?.url)
269
+ .filter((value) => typeof value === 'string' && value.trim())
270
+ : (Array.isArray(params.mediaUrls) ? params.mediaUrls : []);
299
271
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction channel send result: ok=${result.ok}, messageId=${result.messageId || "none"}`);
300
272
  const evidence = {
301
273
  ok: result.ok,
@@ -304,8 +276,8 @@ const plugin = createChatChannelPlugin({
304
276
  // and hasGatewayAgentDeliveredExpectedMedia
305
277
  didSendViaMessagingTool: true,
306
278
  messagingToolSentTexts: text ? [text] : [],
307
- messagingToolSentMediaUrls: sentMediaUrls,
308
- messagingToolSentTargets: [{ to: target, ...(sentMediaUrls.length > 0 ? { mediaUrls: sentMediaUrls } : {}) }],
279
+ messagingToolSentMediaUrls: deliveredMediaUrls,
280
+ messagingToolSentTargets: [{ to: target, ...(deliveredMediaUrls.length > 0 ? { mediaUrls: deliveredMediaUrls } : {}) }],
309
281
  };
310
282
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction returning evidence to OpenClaw: didSendViaMessagingTool=${evidence.didSendViaMessagingTool}, sentMediaUrlsCount=${evidence.messagingToolSentMediaUrls.length}`);
311
283
  return evidence;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const dragonChannelPluginVersion = "0.5.35";
1
+ export declare const dragonChannelPluginVersion = "0.5.37";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const dragonChannelPluginVersion = "0.5.35";
1
+ export const dragonChannelPluginVersion = "0.5.37";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efengx/openclaw-channel-dragon",
3
- "version": "0.5.35",
3
+ "version": "0.5.37",
4
4
  "description": "Dragon workbench channel for OpenClaw",
5
5
  "author": "feng xiang <ofengx@gmail.com>",
6
6
  "type": "module",