@workclaw/openclaw-workclaw 1.0.18 → 1.0.20

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.
Files changed (145) hide show
  1. package/README.md +30 -11
  2. package/dist/api.d.ts +3 -0
  3. package/dist/api.js +3 -0
  4. package/dist/index.d.ts +11 -0
  5. package/dist/index.js +264 -0
  6. package/dist/setup-entry.d.ts +2 -0
  7. package/dist/setup-entry.js +12 -0
  8. package/dist/src/accounts.d.ts +66 -0
  9. package/dist/src/accounts.js +279 -0
  10. package/dist/src/api/accounts-api.d.ts +2 -0
  11. package/dist/src/api/accounts-api.js +130 -0
  12. package/dist/src/api/prompts-api.d.ts +2 -0
  13. package/dist/src/api/prompts-api.js +103 -0
  14. package/dist/src/api/session-api.d.ts +2 -0
  15. package/dist/src/api/session-api.js +207 -0
  16. package/dist/src/api/skills-api.d.ts +2 -0
  17. package/dist/src/api/skills-api.js +64 -0
  18. package/dist/src/api/workspace.d.ts +3 -0
  19. package/dist/src/api/workspace.js +30 -0
  20. package/dist/src/channel.d.ts +11 -0
  21. package/{src/channel.ts → dist/src/channel.js} +199 -225
  22. package/dist/src/config-schema.d.ts +93 -0
  23. package/dist/src/config-schema.js +56 -0
  24. package/dist/src/connection/workclaw-client.d.ts +147 -0
  25. package/dist/src/connection/workclaw-client.js +351 -0
  26. package/dist/src/gateway/agent-handlers.d.ts +12 -0
  27. package/{src/gateway/agent-handlers.ts → dist/src/gateway/agent-handlers.js} +372 -462
  28. package/dist/src/gateway/config-writer.d.ts +71 -0
  29. package/dist/src/gateway/config-writer.js +302 -0
  30. package/dist/src/gateway/cron-tasks-handler.d.ts +19 -0
  31. package/dist/src/gateway/cron-tasks-handler.js +188 -0
  32. package/dist/src/gateway/message-context.d.ts +80 -0
  33. package/{src/gateway/message-context.ts → dist/src/gateway/message-context.js} +509 -594
  34. package/dist/src/gateway/message-dispatcher.d.ts +18 -0
  35. package/dist/src/gateway/message-dispatcher.js +572 -0
  36. package/dist/src/gateway/reconnect.d.ts +27 -0
  37. package/{src/gateway/reconnect.ts → dist/src/gateway/reconnect.js} +210 -253
  38. package/dist/src/gateway/skills-handler.d.ts +29 -0
  39. package/dist/src/gateway/skills-handler.js +615 -0
  40. package/dist/src/gateway/skills-list-handler.d.ts +27 -0
  41. package/dist/src/gateway/skills-list-handler.js +233 -0
  42. package/dist/src/gateway/tools-list-handler.d.ts +30 -0
  43. package/dist/src/gateway/tools-list-handler.js +101 -0
  44. package/dist/src/gateway/workclaw-gateway.d.ts +14 -0
  45. package/dist/src/gateway/workclaw-gateway.js +237 -0
  46. package/dist/src/media/upload.d.ts +13 -0
  47. package/dist/src/media/upload.js +125 -0
  48. package/dist/src/outbound/index.d.ts +36 -0
  49. package/dist/src/outbound/index.js +123 -0
  50. package/dist/src/outbound/workclaw-sender.d.ts +36 -0
  51. package/{src/outbound/workclaw-sender.ts → dist/src/outbound/workclaw-sender.js} +95 -158
  52. package/dist/src/runtime.d.ts +116 -0
  53. package/dist/src/runtime.js +374 -0
  54. package/dist/src/secret-contract-api.d.ts +4 -0
  55. package/dist/src/secret-contract-api.js +4 -0
  56. package/{src/send.ts → dist/src/send.d.ts} +1 -1
  57. package/dist/src/send.js +1 -0
  58. package/dist/src/setup-api.d.ts +3 -0
  59. package/dist/src/setup-api.js +3 -0
  60. package/dist/src/setup-core.d.ts +3 -0
  61. package/dist/src/setup-core.js +13 -0
  62. package/dist/src/setup-surface.d.ts +7 -0
  63. package/dist/src/setup-surface.js +363 -0
  64. package/dist/src/tools/openclaw-workclaw-cron/api/index.d.ts +93 -0
  65. package/dist/src/tools/openclaw-workclaw-cron/api/index.js +209 -0
  66. package/dist/src/tools/openclaw-workclaw-cron/index.d.ts +10 -0
  67. package/{src/tools/openclaw-workclaw-cron/index.ts → dist/src/tools/openclaw-workclaw-cron/index.js} +33 -39
  68. package/dist/src/tools/openclaw-workclaw-cron/src/add/params.d.ts +16 -0
  69. package/dist/src/tools/openclaw-workclaw-cron/src/add/params.js +152 -0
  70. package/dist/src/tools/openclaw-workclaw-cron/src/add/sync.d.ts +18 -0
  71. package/dist/src/tools/openclaw-workclaw-cron/src/add/sync.js +162 -0
  72. package/dist/src/tools/openclaw-workclaw-cron/src/disable/params.d.ts +14 -0
  73. package/dist/src/tools/openclaw-workclaw-cron/src/disable/params.js +80 -0
  74. package/dist/src/tools/openclaw-workclaw-cron/src/disable/sync.d.ts +14 -0
  75. package/dist/src/tools/openclaw-workclaw-cron/src/disable/sync.js +107 -0
  76. package/dist/src/tools/openclaw-workclaw-cron/src/enable/params.d.ts +14 -0
  77. package/dist/src/tools/openclaw-workclaw-cron/src/enable/params.js +80 -0
  78. package/dist/src/tools/openclaw-workclaw-cron/src/enable/sync.d.ts +14 -0
  79. package/dist/src/tools/openclaw-workclaw-cron/src/enable/sync.js +107 -0
  80. package/dist/src/tools/openclaw-workclaw-cron/src/notify/sync.d.ts +17 -0
  81. package/dist/src/tools/openclaw-workclaw-cron/src/notify/sync.js +127 -0
  82. package/dist/src/tools/openclaw-workclaw-cron/src/remove/params.d.ts +14 -0
  83. package/dist/src/tools/openclaw-workclaw-cron/src/remove/params.js +87 -0
  84. package/dist/src/tools/openclaw-workclaw-cron/src/remove/sync.d.ts +14 -0
  85. package/dist/src/tools/openclaw-workclaw-cron/src/remove/sync.js +107 -0
  86. package/dist/src/tools/openclaw-workclaw-cron/src/update/params.d.ts +17 -0
  87. package/dist/src/tools/openclaw-workclaw-cron/src/update/params.js +164 -0
  88. package/dist/src/tools/openclaw-workclaw-cron/src/update/sync.d.ts +18 -0
  89. package/dist/src/tools/openclaw-workclaw-cron/src/update/sync.js +135 -0
  90. package/dist/src/tools/openclaw-workclaw-cron/types/index.d.ts +52 -0
  91. package/dist/src/tools/openclaw-workclaw-cron/types/index.js +4 -0
  92. package/dist/src/tools/openclaw-workclaw-cron/utils/index.d.ts +40 -0
  93. package/dist/src/tools/openclaw-workclaw-cron/utils/index.js +122 -0
  94. package/dist/src/tools/openclaw-workclaw-system/index.d.ts +10 -0
  95. package/{src/tools/openclaw-workclaw-system/index.ts → dist/src/tools/openclaw-workclaw-system/index.js} +15 -17
  96. package/dist/src/tools/openclaw-workclaw-system/src/get/index.d.ts +8 -0
  97. package/dist/src/tools/openclaw-workclaw-system/src/get/index.js +64 -0
  98. package/dist/src/tools/openclaw-workclaw-system/src/token/index.d.ts +8 -0
  99. package/dist/src/tools/openclaw-workclaw-system/src/token/index.js +78 -0
  100. package/{src/types.ts → dist/src/types.d.ts} +39 -58
  101. package/dist/src/types.js +1 -0
  102. package/dist/src/utils/content.d.ts +15 -0
  103. package/{src/utils/content.ts → dist/src/utils/content.js} +38 -35
  104. package/package.json +13 -9
  105. package/index.ts +0 -302
  106. package/setup-entry.ts +0 -6
  107. package/src/accounts.ts +0 -312
  108. package/src/api/accounts-api.ts +0 -156
  109. package/src/api/prompts-api.ts +0 -116
  110. package/src/api/session-api.ts +0 -238
  111. package/src/api/skills-api.ts +0 -72
  112. package/src/api/workspace.ts +0 -41
  113. package/src/config-schema.ts +0 -110
  114. package/src/connection/workclaw-client.ts +0 -643
  115. package/src/gateway/config-writer.ts +0 -296
  116. package/src/gateway/message-dispatcher.ts +0 -641
  117. package/src/gateway/skills-handler.ts +0 -741
  118. package/src/gateway/skills-list-handler.ts +0 -332
  119. package/src/gateway/tools-list-handler.ts +0 -160
  120. package/src/gateway/workclaw-gateway.ts +0 -367
  121. package/src/media/upload.ts +0 -157
  122. package/src/outbound/index.ts +0 -185
  123. package/src/runtime.ts +0 -497
  124. package/src/tools/openclaw-workclaw-cron/api/index.ts +0 -326
  125. package/src/tools/openclaw-workclaw-cron/src/add/params.ts +0 -177
  126. package/src/tools/openclaw-workclaw-cron/src/add/sync.ts +0 -188
  127. package/src/tools/openclaw-workclaw-cron/src/disable/params.ts +0 -100
  128. package/src/tools/openclaw-workclaw-cron/src/disable/sync.ts +0 -127
  129. package/src/tools/openclaw-workclaw-cron/src/enable/params.ts +0 -100
  130. package/src/tools/openclaw-workclaw-cron/src/enable/sync.ts +0 -127
  131. package/src/tools/openclaw-workclaw-cron/src/notify/sync.ts +0 -148
  132. package/src/tools/openclaw-workclaw-cron/src/remove/params.ts +0 -109
  133. package/src/tools/openclaw-workclaw-cron/src/remove/sync.ts +0 -127
  134. package/src/tools/openclaw-workclaw-cron/src/update/params.ts +0 -195
  135. package/src/tools/openclaw-workclaw-cron/src/update/sync.ts +0 -161
  136. package/src/tools/openclaw-workclaw-cron/types/index.ts +0 -55
  137. package/src/tools/openclaw-workclaw-cron/utils/index.ts +0 -141
  138. package/src/tools/openclaw-workclaw-system/src/get/index.ts +0 -77
  139. package/src/tools/openclaw-workclaw-system/src/token/index.ts +0 -93
  140. package/tests/accounts.test.ts +0 -285
  141. package/tests/message-context.test.ts +0 -313
  142. package/tests/reconnect.test.ts +0 -257
  143. package/tests/workclaw-client.test.ts +0 -112
  144. package/tsconfig.json +0 -14
  145. package/vitest.config.ts +0 -8
@@ -1,594 +1,509 @@
1
- import type { WorkclawConfig } from "../types.js";
2
- import type { PluginRuntime } from "openclaw/plugin-sdk";
3
- import fs from "node:fs/promises";
4
- import path from "node:path";
5
- import os from "node:os";
6
- import https from "node:https";
7
- import http from "node:http";
8
-
9
- // 附件接口
10
- export interface Attachment {
11
- url: string
12
- fileId: string
13
- type: 'image' | 'file'
14
- name?: string
15
- }
16
-
17
- /** Channel/plugin name constant */
18
- export const OPENCLAW_WORKCLAW_CHANNEL = "openclaw-workclaw";
19
-
20
- export interface InboundMessage {
21
- text: string;
22
- to: string;
23
- from: string;
24
- chatType: "dm" | "group";
25
- messageId: string;
26
- rawPayload: string;
27
- timestamp: number;
28
- attachments?: Attachment[] // 文件 URL 列表
29
- }
30
-
31
- export interface InboundContextPayload {
32
- [key: string]: unknown;
33
- Body: string;
34
- BodyForAgent: string;
35
- RawBody: string;
36
- From: string;
37
- To: string;
38
- ChatType: "dm" | "group";
39
- Provider: string;
40
- Surface: string;
41
- Timestamp: number;
42
- AccountId: string;
43
- AgentId?: string;
44
- MessageSid?: string;
45
- OriginatingChannel: string;
46
- OriginatingTo: string;
47
- }
48
-
49
- export type WorkClawMessageType =
50
- | "agent_message" // 正常的业务消息
51
- | "ping" // 需要回复 pong 的消息
52
- | "disconnect" // 断开连接的消息
53
- | "agent_created" // 创建账户事件
54
- | "agent_updated" // 更新账户事件
55
- | "agent_deleted" // 删除账户事件
56
- | "tools_list" // 获取工具列表事件
57
- | "skills_list" // 获取 skills 列表事件
58
- | "skills_event" // skills 相关事件(create/update/delete/list/get/invoke)
59
- | "init_agent" // 初始化智能体事件
60
- | "ignored"; // 忽略的消息(未知类型)
61
-
62
- export interface ParseWorkClawResult {
63
- type: WorkClawMessageType;
64
- message?: {
65
- text: string;
66
- userId: string;
67
- openConversationId: string;
68
- messageId: string;
69
- agentId?: string | number;
70
- attachments?: Attachment[]
71
- };
72
- pongData?: any; // 用于回复 pong 的数据
73
- eventData?: any; // 事件数据(用于 AGENT_CREATED/UPDATED/DELETED 及 skills 事件)
74
- }
75
-
76
- export function parseInboundMessage(data: string, allowRawJsonPayload?: boolean): Partial<InboundMessage> {
77
- let input: any = {};
78
- try {
79
- input = data ? JSON.parse(data) : {};
80
- } catch {
81
- // Not JSON, treat as plain text
82
- }
83
-
84
- const text =
85
- typeof input.text === "string"
86
- ? input.text
87
- : typeof input.message === "string"
88
- ? input.message
89
- : typeof input.body === "string"
90
- ? input.body
91
- : data;
92
-
93
- const to = String(input.to ?? input.chatId ?? input.target ?? "");
94
- const from = String(input.from ?? input.senderId ?? input.userId ?? "");
95
- const chatType = String(input.chatType || "").toLowerCase() === "group" ? "group" : "dm";
96
- const messageId = String(input.messageId ?? input.id ?? "");
97
- const rawPayload = allowRawJsonPayload ? JSON.stringify(input ?? {}) : data;
98
-
99
- return {
100
- text,
101
- to,
102
- from,
103
- chatType,
104
- messageId,
105
- rawPayload,
106
- };
107
- }
108
-
109
- export function parseWorkClawMessage(data: string, log?: {
110
- info?: (msg: string) => void;
111
- warn?: (msg: string) => void;
112
- error?: (msg: string) => void;
113
- }): ParseWorkClawResult | null {
114
- //{"specVersion":"1.0","type":"EVENT","metadata":{"time":"1773749163831","event":"INIT_AGENT","contentType":"application/json"},"data":"\"12557533957824965\""}
115
- let input: any = {};
116
- try {
117
- input = data ? JSON.parse(data) : {};
118
- } catch {
119
- log?.info?.(`Failed to parse message data=${data}`);
120
- return null;
121
- }
122
-
123
- // Handle plain text ping
124
- if (typeof input === "string" && input.toLowerCase() === "ping") {
125
- return {
126
- type: "ping",
127
- pongData: input,
128
- };
129
- }
130
-
131
- const envelope = input && typeof input === "object" ? input : { data: input };
132
- const topic = envelope?.metadata?.topic;
133
- const type = String(envelope?.type || "").trim().toUpperCase();
134
- const topicLower = String(topic || "").trim().toLowerCase();
135
-
136
- if (type === "INIT_AGENT") {
137
- let parsedData = envelope?.data;
138
- if (typeof parsedData === "string") {
139
- try {
140
- parsedData = JSON.parse(parsedData);
141
- } catch (e) {
142
- // 解析失败,保持原样
143
- }
144
- }
145
-
146
- return {
147
- type: "init_agent",
148
- eventData: parsedData,
149
- };
150
- }
151
-
152
- // Handle SYSTEM messages
153
- if (type === "SYSTEM") {
154
- if (topicLower === "ping") {
155
- return {
156
- type: "ping",
157
- pongData: envelope?.data,
158
- };
159
- }
160
- if (topicLower === "disconnect") {
161
- return {
162
- type: "disconnect",
163
- };
164
- }
165
- // Unknown system message type, ignore
166
- return {
167
- type: "ignored",
168
- };
169
- }
170
-
171
- // Handle EVENT messages (AGENT_CREATED, AGENT_UPDATED, AGENT_DELETED, TOOLS_LIST, SKILLS_LIST, INIT_AGENT)
172
- if (type === "EVENT") {
173
- const eventType = String(envelope?.metadata?.event || "").trim().toUpperCase();
174
-
175
- // 解析 data 字段(可能是 JSON 字符串)
176
- let parsedData = envelope?.data;
177
- if (typeof parsedData === "string") {
178
- try {
179
- parsedData = JSON.parse(parsedData);
180
- } catch (e) {
181
- // 解析失败,保持原样
182
- }
183
- }
184
-
185
- if (eventType === "AGENT_CREATED") {
186
- return {
187
- type: "agent_created",
188
- eventData: parsedData,
189
- };
190
- }
191
- if (eventType === "AGENT_UPDATED") {
192
- return {
193
- type: "agent_updated",
194
- eventData: parsedData,
195
- };
196
- }
197
- if (eventType === "AGENT_DELETED") {
198
- return {
199
- type: "agent_deleted",
200
- eventData: parsedData,
201
- };
202
- }
203
- if (eventType === "TOOLS_LIST") {
204
- return {
205
- type: "tools_list",
206
- eventData: parsedData,
207
- };
208
- }
209
- if (eventType === "SKILLS_LIST") {
210
- return {
211
- type: "skills_list",
212
- eventData: parsedData,
213
- };
214
- }
215
- if (eventType === "INIT_AGENT") {
216
- return {
217
- type: "init_agent",
218
- eventData: parsedData,
219
- };
220
- }
221
-
222
- if (eventType === "SKILL_DO_REMIND") {
223
- const eventTopic = String(parsedData?.dotype || "").toLowerCase();
224
- return {
225
- type: "skills_event",
226
- eventData: {
227
- topic: "skills/" + eventTopic,
228
- requestId: "",
229
- callbackUrl: "",
230
- authToken: "",
231
- data: parsedData,
232
- },
233
- };
234
- }
235
-
236
- // Unknown event type, ignore
237
- return {
238
- type: "ignored",
239
- };
240
- }
241
-
242
- // Only process CALLBACK type with AGENT_MESSAGE topic
243
- if (type !== "CALLBACK" || String(topic || "").trim().toUpperCase() !== "AGENT_MESSAGE") {
244
- return {
245
- type: "ignored",
246
- };
247
- }
248
-
249
- // 解析 data 字段(可能是 JSON 字符串)
250
- let payload = envelope?.data || {};
251
- if (typeof payload === "string") {
252
- try {
253
- payload = JSON.parse(payload);
254
- } catch (e) {
255
- // 解析失败,保持原样
256
- }
257
- }
258
-
259
- const text =
260
- typeof payload?.message?.content === "string"
261
- ? payload.message.content
262
- : typeof payload?.message === "string"
263
- ? payload.message
264
- : "";
265
-
266
- // 判断文件是否为图片
267
- function isImageFile(url: string): boolean {
268
- const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'];
269
- const lowerUrl = url.toLowerCase();
270
- return imageExtensions.some(ext => lowerUrl.endsWith(ext));
271
- }
272
-
273
- // 解析 fileUrl 列表到 attachments(供 SDK 和 AI 使用)
274
- const fileUrlList: string[] = Array.isArray(payload?.fileUrl) ? payload.fileUrl : [];
275
- const attachments: Attachment[] = fileUrlList.map((url) => ({
276
- url,
277
- fileId: payload?.fileId ? String(payload.fileId) : '',
278
- type: isImageFile(url) ? 'image' : 'file',
279
- }));
280
-
281
- return {
282
- type: "agent_message",
283
- message: {
284
- text,
285
- userId: String(payload?.userId ?? ""),
286
- openConversationId: String(payload?.conversationId ?? ""),
287
- messageId: String(payload?.msgId ?? ""),
288
- agentId: payload?.agentId,
289
- attachments: attachments.length > 0 ? attachments : undefined,
290
- },
291
- };
292
- }
293
-
294
- /**
295
- * 从 URL 扩展名判断媒体类型
296
- */
297
- function detectMediaType(url: string): string {
298
- const lower = url.toLowerCase();
299
- if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png")
300
- || lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp")
301
- || lower.endsWith(".svg") || lower.endsWith(".ico")) {
302
- return "image";
303
- }
304
- if (lower.endsWith(".mp3") || lower.endsWith(".wav") || lower.endsWith(".amr")
305
- || lower.endsWith(".m4a") || lower.endsWith(".ogg")) {
306
- return "audio";
307
- }
308
- if (lower.endsWith(".mp4") || lower.endsWith(".avi") || lower.endsWith(".mov")
309
- || lower.endsWith(".mkv") || lower.endsWith(".webm")) {
310
- return "video";
311
- }
312
- if (lower.endsWith(".pdf")) return "application/pdf";
313
- if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
314
- if (lower.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
315
- if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
316
- if (lower.endsWith(".doc")) return "application/msword";
317
- if (lower.endsWith(".xls")) return "application/vnd.ms-excel";
318
- if (lower.endsWith(".ppt")) return "application/vnd.ms-powerpoint";
319
- if (lower.endsWith(".txt")) return "text/plain";
320
- if (lower.endsWith(".zip")) return "application/zip";
321
- if (lower.endsWith(".rar")) return "application/x-rar-compressed";
322
- return "application/octet-stream";
323
- }
324
-
325
- /**
326
- * URL 扩展名获取人类可读的类型标签
327
- */
328
- function getMediaTypeLabel(url: string): string {
329
- const lower = url.toLowerCase();
330
- if (lower.endsWith(".pdf")) return "PDF";
331
- if (lower.endsWith(".docx")) return "Word";
332
- if (lower.endsWith(".doc")) return "Word";
333
- if (lower.endsWith(".xlsx")) return "Excel";
334
- if (lower.endsWith(".xls")) return "Excel";
335
- if (lower.endsWith(".pptx")) return "PowerPoint";
336
- if (lower.endsWith(".ppt")) return "PowerPoint";
337
- if (lower.endsWith(".txt")) return "文本";
338
- if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "JPG图片";
339
- if (lower.endsWith(".png")) return "PNG图片";
340
- if (lower.endsWith(".gif")) return "GIF图片";
341
- if (lower.endsWith(".webp")) return "WebP图片";
342
- if (lower.endsWith(".mp3")) return "音频";
343
- if (lower.endsWith(".wav")) return "音频";
344
- if (lower.endsWith(".mp4")) return "视频";
345
- if (lower.endsWith(".zip") || lower.endsWith(".rar")) return "压缩包";
346
- return "文件";
347
- }
348
-
349
- /**
350
- * 下载文件到本地下载目录(按 accountId/peerId 隔离),返回本地路径
351
- */
352
- async function downloadFileToLocal(
353
- url: string,
354
- accountId: string,
355
- peerId: string,
356
- log?: { info?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void },
357
- ): Promise<string | null> {
358
- const downloadsDir = path.join(os.homedir(), ".openclaw", "media", "workclaw", "downloads", accountId, peerId);
359
- try {
360
- await fs.mkdir(downloadsDir, { recursive: true });
361
- } catch {}
362
-
363
- const urlObj = new URL(url);
364
- const fileName = path.basename(urlObj.pathname) || "file";
365
- const ext = path.extname(fileName);
366
- const stem = path.basename(fileName, ext);
367
- const localFileName = `${stem}_${Date.now()}${ext}`;
368
- const localFilePath = path.join(downloadsDir, localFileName);
369
-
370
- return new Promise((resolve) => {
371
- const client = url.startsWith("https:") ? https : http;
372
- const req = client.get(url, (res) => {
373
- if (res.statusCode !== 200) {
374
- log?.warn?.(`downloadFile: HTTP ${res.statusCode} for ${url}`);
375
- resolve(null);
376
- return;
377
- }
378
- const chunks: Buffer[] = [];
379
- res.on("data", (chunk: Buffer) => chunks.push(chunk));
380
- res.on("end", async () => {
381
- try {
382
- const buffer = Buffer.concat(chunks);
383
- await fs.writeFile(localFilePath, buffer);
384
- log?.info?.(`downloadFile: saved ${buffer.length} bytes -> ${localFilePath}`);
385
- resolve(localFilePath);
386
- } catch (err) {
387
- log?.error?.(`downloadFile: write failed: ${String(err)}`);
388
- resolve(null);
389
- }
390
- });
391
- res.on("error", (err) => {
392
- log?.error?.(`downloadFile: read error: ${String(err)}`);
393
- resolve(null);
394
- });
395
- });
396
- req.on("error", (err) => {
397
- log?.error?.(`downloadFile: request error: ${String(err)}`);
398
- resolve(null);
399
- });
400
- req.setTimeout(30000, () => {
401
- req.destroy();
402
- log?.warn?.(`downloadFile: timeout for ${url}`);
403
- resolve(null);
404
- });
405
- });
406
- }
407
-
408
- /**
409
- * Build inbound context for WorkClaw channel.
410
- *
411
- * When pluginRuntime is provided, performs full session handling:
412
- * - resolveStorePath, readSessionUpdatedAt, formatInboundEnvelope
413
- * - finalizeInboundContext, recordInboundSession
414
- *
415
- * @param message - The inbound message
416
- * @param accountId - Account ID
417
- * @param config - Account config (WorkclawConfig)
418
- * @param agentId - Target agent ID (optional)
419
- * @param pluginRuntime - OpenClaw plugin runtime (optional, enables full session handling)
420
- * @param recordSession - Whether to record session (default true)
421
- * @param log - Optional logger
422
- */
423
- export async function buildInboundContext(
424
- message: InboundMessage,
425
- accountId: string,
426
- config: any,
427
- agentId?: string,
428
- pluginRuntime?: PluginRuntime,
429
- recordSession: boolean = true,
430
- log?: { info?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void },
431
- ): Promise<InboundContextPayload> {
432
- // 尝试从 rawPayload 中提取已有的 sessionKey(新建话题时 openclaw 会生成新的 session)
433
- let existingSessionKey: string | undefined;
434
- try {
435
- const rawPayload = JSON.parse(message.rawPayload);
436
- existingSessionKey = rawPayload.sessionKey;
437
- } catch {
438
- // ignore
439
- }
440
-
441
- const peerId = message.from;
442
- const computedSessionKey = existingSessionKey
443
- || (agentId && agentId !== "main"
444
- ? `agent:${agentId}:openclaw-workclaw:${accountId}:direct:${peerId}`
445
- : undefined);
446
-
447
- const sessionKey = computedSessionKey || (agentId && agentId !== "main"
448
- ? `agent:${agentId}:openclaw-workclaw:${accountId}:direct:${peerId}`
449
- : undefined);
450
-
451
- // Basic context (used as base for both paths)
452
- // parseWorkClawMessage 已将 fileUrl 列表追加到 message.text 中
453
- const result: InboundContextPayload = {
454
- Body: message.text,
455
- BodyForAgent: message.text,
456
- RawBody: JSON.stringify(message),
457
- From: message.from,
458
- To: message.to,
459
- ChatType: message.chatType,
460
- Provider: OPENCLAW_WORKCLAW_CHANNEL,
461
- Surface: OPENCLAW_WORKCLAW_CHANNEL,
462
- Timestamp: message.timestamp,
463
- AccountId: accountId,
464
- AgentId: agentId,
465
- MessageSid: message.messageId,
466
- OriginatingChannel: OPENCLAW_WORKCLAW_CHANNEL,
467
- OriginatingTo: message.to,
468
- };
469
-
470
- if (sessionKey) {
471
- result.SessionKey = sessionKey;
472
- }
473
-
474
- // If no pluginRuntime provided, return basic context without session recording
475
- if (!pluginRuntime) {
476
- return result;
477
- }
478
-
479
- // Full session handling path
480
- const storePath = pluginRuntime.channel.session.resolveStorePath(config.session?.store, { agentId });
481
- log?.info?.(`[Session] storePath=${storePath}, agentId=${agentId}, sessionKey=${sessionKey}`);
482
-
483
- // 获取 envelope 格式选项
484
- const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(config);
485
-
486
- // 获取上次的 session 更新时间
487
- const previousTimestamp = pluginRuntime.channel.session.readSessionUpdatedAt({
488
- storePath,
489
- sessionKey: sessionKey!,
490
- });
491
- log?.info?.(`[Session] previousTimestamp=${previousTimestamp}`);
492
-
493
- // 处理附件:下载 fileUrl 到本地,并收集媒体信息
494
- let mediaUrls: string[] = [];
495
- let mediaTypes: string[] = [];
496
- let bodyText = message.text;
497
- if (message.attachments && message.attachments.length > 0) {
498
- log?.info?.(`[Attachments] Downloading ${message.attachments.length} files...`);
499
-
500
- // 并行下载所有附件
501
- const downloadResults = await Promise.all(
502
- message.attachments.map(async (att) => {
503
- const localPath = await downloadFileToLocal(att.url, accountId, message.from, log);
504
- const mediaType = detectMediaType(att.url);
505
- const typeLabel = getMediaTypeLabel(att.url);
506
- return { att, localPath, mediaType, typeLabel };
507
- })
508
- );
509
-
510
- // 收集下载成功的文件信息(MediaUrls 由 SDK 用于显示 [media attached])
511
- for (const { att, localPath, mediaType } of downloadResults) {
512
- if (localPath) {
513
- // 将绝对路径转为根相对路径(去掉 ~/.openclaw/ 前缀,转为 /.openclaw/... 形式)
514
- // OpenClaw 的 skill 系统会 block 绝对路径(C:\...)和 ~ 路径
515
- // 兼容 Windows 路径格式(C:\Users\用户名\.openclaw\... 或 C:\Users\用户名.openclaw\...)
516
- const homeDir = os.homedir().replace(/\\/g, '/').trim(); // 统一用正斜杠,去除尾随空格
517
- const normalizedLocalPath = localPath.replace(/\\/g, '/');
518
- const relativePath = normalizedLocalPath.startsWith(homeDir + '/.openclaw/')
519
- ? '/' + normalizedLocalPath.slice(homeDir.length + 1) // 用 / 开头表示根相对路径
520
- : normalizedLocalPath;
521
- mediaUrls.push(relativePath);
522
- mediaTypes.push(mediaType);
523
- log?.info?.(`[Attachments] Downloaded: ${relativePath}`);
524
- } else {
525
- log?.warn?.(`[Attachments] Failed to download: ${att.url}`);
526
- }
527
- }
528
-
529
- log?.info?.(`[Attachments] Downloaded ${mediaUrls.length}/${message.attachments.length} files`);
530
- }
531
- const fromLabel = message.to;
532
- const body = pluginRuntime.channel.reply.formatInboundEnvelope({
533
- channel: OPENCLAW_WORKCLAW_CHANNEL,
534
- from: fromLabel,
535
- timestamp: message.timestamp,
536
- body: bodyText,
537
- chatType: message.chatType,
538
- sender: { name: message.to, id: message.to },
539
- previousTimestamp,
540
- envelope: envelopeOptions,
541
- });
542
-
543
- // 构建完整的 ctx
544
- const finalized = pluginRuntime.channel.reply.finalizeInboundContext({
545
- Body: body,
546
- BodyForAgent: bodyText,
547
- RawBody: JSON.stringify(message),
548
- CommandBody: message.text,
549
- From: message.from,
550
- To: message.to,
551
- SessionKey: sessionKey,
552
- AccountId: accountId,
553
- ChatType: message.chatType,
554
- ConversationLabel: fromLabel,
555
- Provider: OPENCLAW_WORKCLAW_CHANNEL,
556
- Surface: "openclaw-workclaw_dm",
557
- MessageSid: message.messageId,
558
- Timestamp: message.timestamp,
559
- OriginatingChannel: OPENCLAW_WORKCLAW_CHANNEL,
560
- OriginatingTo: message.to,
561
- // 附件媒体信息(供 skill 读取本地文件)
562
- MediaPath: mediaUrls[0] || undefined,
563
- MediaUrl: mediaUrls[0] || undefined,
564
- MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
565
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
566
- });
567
-
568
- // 记录 session(需要在 dispatchReplyWithBufferedBlockDispatcher 之前调用)
569
- // 这样 OpenClaw 才能在工具执行时正确关联 session
570
- if (sessionKey && recordSession) {
571
- try {
572
- await pluginRuntime.channel.session.recordInboundSession({
573
- storePath,
574
- sessionKey: finalized.SessionKey || sessionKey,
575
- ctx: finalized,
576
- updateLastRoute: {
577
- sessionKey,
578
- channel: OPENCLAW_WORKCLAW_CHANNEL,
579
- to: message.to,
580
- accountId,
581
- },
582
- onRecordError: (err: unknown) => {
583
- log?.error?.(`[Session] recordInboundSession failed: ${String(err)}`);
584
- },
585
- });
586
- log?.info?.(`[Session] Recorded session for key=${sessionKey}`);
587
- } catch (err) {
588
- log?.warn?.(`[Session] recordInboundSession error: ${String(err)}`);
589
- }
590
- }
591
-
592
- return finalized;
593
- }
594
-
1
+ import fs from 'node:fs/promises';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ /** Channel/plugin name constant */
7
+ export const OPENCLAW_WORKCLAW_CHANNEL = 'openclaw-workclaw';
8
+ export function parseInboundMessage(data, allowRawJsonPayload) {
9
+ let input = {};
10
+ try {
11
+ input = data ? JSON.parse(data) : {};
12
+ }
13
+ catch {
14
+ // Not JSON, treat as plain text
15
+ }
16
+ const text = typeof input.text === 'string'
17
+ ? input.text
18
+ : typeof input.message === 'string'
19
+ ? input.message
20
+ : typeof input.body === 'string'
21
+ ? input.body
22
+ : data;
23
+ const to = String(input.to ?? input.chatId ?? input.target ?? '');
24
+ const from = String(input.from ?? input.senderId ?? input.userId ?? '');
25
+ const chatType = String(input.chatType || '').toLowerCase() === 'group' ? 'group' : 'dm';
26
+ const messageId = String(input.messageId ?? input.id ?? '');
27
+ const rawPayload = allowRawJsonPayload ? JSON.stringify(input ?? {}) : data;
28
+ return {
29
+ text,
30
+ to,
31
+ from,
32
+ chatType,
33
+ messageId,
34
+ rawPayload,
35
+ };
36
+ }
37
+ export function parseWorkClawMessage(data, log) {
38
+ // {"specVersion":"1.0","type":"EVENT","metadata":{"time":"1773749163831","event":"INIT_AGENT","contentType":"application/json"},"data":"\"12557533957824965\""}
39
+ let input = {};
40
+ try {
41
+ input = data ? JSON.parse(data) : {};
42
+ }
43
+ catch {
44
+ log?.info?.(`Failed to parse message data=${data}`);
45
+ return null;
46
+ }
47
+ // Handle plain text ping
48
+ if (typeof input === 'string' && input.toLowerCase() === 'ping') {
49
+ return {
50
+ type: 'ping',
51
+ pongData: input,
52
+ };
53
+ }
54
+ const envelope = input && typeof input === 'object' ? input : { data: input };
55
+ const topic = envelope?.metadata?.topic;
56
+ const type = String(envelope?.type || '').trim().toUpperCase();
57
+ const topicLower = String(topic || '').trim().toLowerCase();
58
+ if (type === 'INIT_AGENT') {
59
+ let parsedData = envelope?.data;
60
+ if (typeof parsedData === 'string') {
61
+ try {
62
+ parsedData = JSON.parse(parsedData);
63
+ }
64
+ catch (e) {
65
+ // 解析失败,保持原样
66
+ }
67
+ }
68
+ return {
69
+ type: 'init_agent',
70
+ eventData: parsedData,
71
+ };
72
+ }
73
+ // Handle SYSTEM messages
74
+ if (type === 'SYSTEM') {
75
+ if (topicLower === 'ping') {
76
+ return {
77
+ type: 'ping',
78
+ pongData: envelope?.data,
79
+ };
80
+ }
81
+ if (topicLower === 'disconnect') {
82
+ return {
83
+ type: 'disconnect',
84
+ };
85
+ }
86
+ // Unknown system message type, ignore
87
+ return {
88
+ type: 'ignored',
89
+ };
90
+ }
91
+ // Handle EVENT messages (AGENT_CREATED, AGENT_UPDATED, AGENT_DELETED, TOOLS_LIST, SKILLS_LIST, INIT_AGENT)
92
+ if (type === 'EVENT') {
93
+ const eventType = String(envelope?.metadata?.event || '').trim().toUpperCase();
94
+ // 解析 data 字段(可能是 JSON 字符串)
95
+ let parsedData = envelope?.data;
96
+ if (typeof parsedData === 'string') {
97
+ try {
98
+ parsedData = JSON.parse(parsedData);
99
+ }
100
+ catch (e) {
101
+ // 解析失败,保持原样
102
+ }
103
+ }
104
+ if (eventType === 'AGENT_CREATED') {
105
+ return {
106
+ type: 'agent_created',
107
+ eventData: parsedData,
108
+ };
109
+ }
110
+ if (eventType === 'AGENT_UPDATED') {
111
+ return {
112
+ type: 'agent_updated',
113
+ eventData: parsedData,
114
+ };
115
+ }
116
+ if (eventType === 'AGENT_DELETED') {
117
+ return {
118
+ type: 'agent_deleted',
119
+ eventData: parsedData,
120
+ };
121
+ }
122
+ if (eventType === 'TOOLS_LIST') {
123
+ return {
124
+ type: 'tools_list',
125
+ eventData: parsedData,
126
+ };
127
+ }
128
+ if (eventType === 'SKILLS_LIST') {
129
+ return {
130
+ type: 'skills_list',
131
+ eventData: parsedData,
132
+ };
133
+ }
134
+ if (eventType === 'INIT_AGENT') {
135
+ return {
136
+ type: 'init_agent',
137
+ eventData: parsedData,
138
+ };
139
+ }
140
+ if (eventType === 'SKILL_DO_REMIND') {
141
+ const eventTopic = String(parsedData?.dotype || '').toLowerCase();
142
+ return {
143
+ type: 'skills_event',
144
+ eventData: {
145
+ topic: `skills/${eventTopic}`,
146
+ requestId: '',
147
+ callbackUrl: '',
148
+ authToken: '',
149
+ data: parsedData,
150
+ },
151
+ };
152
+ }
153
+ if (eventType === 'CRON_REMIND') {
154
+ const eventTopic = String(parsedData?.dotype || '').toLowerCase();
155
+ return {
156
+ type: 'cron_task_event',
157
+ cronTaskEvent: {
158
+ dotype: `${eventTopic}`,
159
+ data: parsedData,
160
+ },
161
+ };
162
+ }
163
+ // Unknown event type, ignore
164
+ return {
165
+ type: 'ignored',
166
+ };
167
+ }
168
+ // Only process CALLBACK type with AGENT_MESSAGE topic
169
+ if (type !== 'CALLBACK' || String(topic || '').trim().toUpperCase() !== 'AGENT_MESSAGE') {
170
+ return {
171
+ type: 'ignored',
172
+ };
173
+ }
174
+ // 解析 data 字段(可能是 JSON 字符串)
175
+ let payload = envelope?.data || {};
176
+ if (typeof payload === 'string') {
177
+ try {
178
+ payload = JSON.parse(payload);
179
+ }
180
+ catch (e) {
181
+ // 解析失败,保持原样
182
+ }
183
+ }
184
+ const text = typeof payload?.message?.content === 'string'
185
+ ? payload.message.content
186
+ : typeof payload?.message === 'string'
187
+ ? payload.message
188
+ : '';
189
+ // 判断文件是否为图片
190
+ function isImageFile(url) {
191
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'];
192
+ const lowerUrl = url.toLowerCase();
193
+ return imageExtensions.some(ext => lowerUrl.endsWith(ext));
194
+ }
195
+ // 解析 fileUrl 列表到 attachments(供 SDK 和 AI 使用)
196
+ const fileUrlList = Array.isArray(payload?.fileUrl) ? payload.fileUrl : [];
197
+ const attachments = fileUrlList.map(url => ({
198
+ url,
199
+ fileId: payload?.fileId ? String(payload.fileId) : '',
200
+ type: isImageFile(url) ? 'image' : 'file',
201
+ }));
202
+ return {
203
+ type: 'agent_message',
204
+ message: {
205
+ text,
206
+ userId: String(payload?.userId ?? ''),
207
+ openConversationId: String(payload?.conversationId ?? ''),
208
+ messageId: String(payload?.msgId ?? ''),
209
+ agentId: payload?.agentId,
210
+ attachments: attachments.length > 0 ? attachments : undefined,
211
+ },
212
+ };
213
+ }
214
+ /**
215
+ * URL 扩展名判断媒体类型
216
+ */
217
+ function detectMediaType(url) {
218
+ const lower = url.toLowerCase();
219
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.png')
220
+ || lower.endsWith('.gif') || lower.endsWith('.webp') || lower.endsWith('.bmp')
221
+ || lower.endsWith('.svg') || lower.endsWith('.ico')) {
222
+ return 'image';
223
+ }
224
+ if (lower.endsWith('.mp3') || lower.endsWith('.wav') || lower.endsWith('.amr')
225
+ || lower.endsWith('.m4a') || lower.endsWith('.ogg')) {
226
+ return 'audio';
227
+ }
228
+ if (lower.endsWith('.mp4') || lower.endsWith('.avi') || lower.endsWith('.mov')
229
+ || lower.endsWith('.mkv') || lower.endsWith('.webm')) {
230
+ return 'video';
231
+ }
232
+ if (lower.endsWith('.pdf'))
233
+ return 'application/pdf';
234
+ if (lower.endsWith('.docx'))
235
+ return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
236
+ if (lower.endsWith('.xlsx'))
237
+ return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
238
+ if (lower.endsWith('.pptx'))
239
+ return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
240
+ if (lower.endsWith('.doc'))
241
+ return 'application/msword';
242
+ if (lower.endsWith('.xls'))
243
+ return 'application/vnd.ms-excel';
244
+ if (lower.endsWith('.ppt'))
245
+ return 'application/vnd.ms-powerpoint';
246
+ if (lower.endsWith('.txt'))
247
+ return 'text/plain';
248
+ if (lower.endsWith('.zip'))
249
+ return 'application/zip';
250
+ if (lower.endsWith('.rar'))
251
+ return 'application/x-rar-compressed';
252
+ return 'application/octet-stream';
253
+ }
254
+ /**
255
+ * 从 URL 扩展名获取人类可读的类型标签
256
+ */
257
+ function getMediaTypeLabel(url) {
258
+ const lower = url.toLowerCase();
259
+ if (lower.endsWith('.pdf'))
260
+ return 'PDF';
261
+ if (lower.endsWith('.docx'))
262
+ return 'Word';
263
+ if (lower.endsWith('.doc'))
264
+ return 'Word';
265
+ if (lower.endsWith('.xlsx'))
266
+ return 'Excel';
267
+ if (lower.endsWith('.xls'))
268
+ return 'Excel';
269
+ if (lower.endsWith('.pptx'))
270
+ return 'PowerPoint';
271
+ if (lower.endsWith('.ppt'))
272
+ return 'PowerPoint';
273
+ if (lower.endsWith('.txt'))
274
+ return '文本';
275
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
276
+ return 'JPG图片';
277
+ if (lower.endsWith('.png'))
278
+ return 'PNG图片';
279
+ if (lower.endsWith('.gif'))
280
+ return 'GIF图片';
281
+ if (lower.endsWith('.webp'))
282
+ return 'WebP图片';
283
+ if (lower.endsWith('.mp3'))
284
+ return '音频';
285
+ if (lower.endsWith('.wav'))
286
+ return '音频';
287
+ if (lower.endsWith('.mp4'))
288
+ return '视频';
289
+ if (lower.endsWith('.zip') || lower.endsWith('.rar'))
290
+ return '压缩包';
291
+ return '文件';
292
+ }
293
+ /**
294
+ * 下载文件到本地下载目录(按 accountId/peerId 隔离),返回本地路径
295
+ */
296
+ async function downloadFileToLocal(url, accountId, peerId, log) {
297
+ const downloadsDir = path.join(os.homedir(), '.openclaw', 'media', 'workclaw', 'downloads', accountId, peerId);
298
+ try {
299
+ await fs.mkdir(downloadsDir, { recursive: true });
300
+ }
301
+ catch { }
302
+ const urlObj = new URL(url);
303
+ const fileName = path.basename(urlObj.pathname) || 'file';
304
+ const ext = path.extname(fileName);
305
+ const stem = path.basename(fileName, ext);
306
+ const localFileName = `${stem}_${Date.now()}${ext}`;
307
+ const localFilePath = path.join(downloadsDir, localFileName);
308
+ return new Promise((resolve) => {
309
+ const client = url.startsWith('https:') ? https : http;
310
+ const req = client.get(url, (res) => {
311
+ if (res.statusCode !== 200) {
312
+ log?.warn?.(`downloadFile: HTTP ${res.statusCode} for ${url}`);
313
+ resolve(null);
314
+ return;
315
+ }
316
+ const chunks = [];
317
+ res.on('data', (chunk) => chunks.push(chunk));
318
+ res.on('end', async () => {
319
+ try {
320
+ const buffer = Buffer.concat(chunks);
321
+ await fs.writeFile(localFilePath, buffer);
322
+ log?.info?.(`downloadFile: saved ${buffer.length} bytes -> ${localFilePath}`);
323
+ resolve(localFilePath);
324
+ }
325
+ catch (err) {
326
+ log?.error?.(`downloadFile: write failed: ${String(err)}`);
327
+ resolve(null);
328
+ }
329
+ });
330
+ res.on('error', (err) => {
331
+ log?.error?.(`downloadFile: read error: ${String(err)}`);
332
+ resolve(null);
333
+ });
334
+ });
335
+ req.on('error', (err) => {
336
+ log?.error?.(`downloadFile: request error: ${String(err)}`);
337
+ resolve(null);
338
+ });
339
+ req.setTimeout(30000, () => {
340
+ req.destroy();
341
+ log?.warn?.(`downloadFile: timeout for ${url}`);
342
+ resolve(null);
343
+ });
344
+ });
345
+ }
346
+ /**
347
+ * Build inbound context for WorkClaw channel.
348
+ *
349
+ * When pluginRuntime is provided, performs full session handling:
350
+ * - resolveStorePath, readSessionUpdatedAt, formatInboundEnvelope
351
+ * - finalizeInboundContext, recordInboundSession
352
+ *
353
+ * @param message - The inbound message
354
+ * @param accountId - Account ID
355
+ * @param config - Account config (WorkclawConfig)
356
+ * @param agentId - Target agent ID (optional)
357
+ * @param pluginRuntime - OpenClaw plugin runtime (optional, enables full session handling)
358
+ * @param recordSession - Whether to record session (default true)
359
+ * @param log - Optional logger
360
+ */
361
+ export async function buildInboundContext(message, accountId, config, agentId, pluginRuntime, recordSession = true, log) {
362
+ // 尝试从 rawPayload 中提取已有的 sessionKey(新建话题时 openclaw 会生成新的 session)
363
+ let existingSessionKey;
364
+ try {
365
+ const rawPayload = JSON.parse(message.rawPayload);
366
+ existingSessionKey = rawPayload.sessionKey;
367
+ }
368
+ catch {
369
+ // ignore
370
+ }
371
+ const peerId = message.from;
372
+ const computedSessionKey = existingSessionKey
373
+ || (agentId && agentId !== 'main'
374
+ ? `agent:${agentId}:openclaw-workclaw:${accountId}:direct:${peerId}`
375
+ : undefined);
376
+ const sessionKey = computedSessionKey || (agentId && agentId !== 'main'
377
+ ? `agent:${agentId}:openclaw-workclaw:${accountId}:direct:${peerId}`
378
+ : undefined);
379
+ // Basic context (used as base for both paths)
380
+ // parseWorkClawMessage 已将 fileUrl 列表追加到 message.text 中
381
+ const result = {
382
+ Body: message.text,
383
+ BodyForAgent: message.text,
384
+ RawBody: JSON.stringify(message),
385
+ From: message.from,
386
+ To: message.to,
387
+ ChatType: message.chatType,
388
+ Provider: OPENCLAW_WORKCLAW_CHANNEL,
389
+ Surface: OPENCLAW_WORKCLAW_CHANNEL,
390
+ Timestamp: message.timestamp,
391
+ AccountId: accountId,
392
+ AgentId: agentId,
393
+ MessageSid: message.messageId,
394
+ OriginatingChannel: OPENCLAW_WORKCLAW_CHANNEL,
395
+ OriginatingTo: message.to,
396
+ };
397
+ if (sessionKey) {
398
+ result.SessionKey = sessionKey;
399
+ }
400
+ // If no pluginRuntime provided, return basic context without session recording
401
+ if (!pluginRuntime) {
402
+ return result;
403
+ }
404
+ // Full session handling path
405
+ const storePath = pluginRuntime.channel.session.resolveStorePath(config.session?.store, { agentId });
406
+ log?.info?.(`[Session] storePath=${storePath}, agentId=${agentId}, sessionKey=${sessionKey}`);
407
+ // 获取 envelope 格式选项
408
+ const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(config);
409
+ // 获取上次的 session 更新时间
410
+ const previousTimestamp = pluginRuntime.channel.session.readSessionUpdatedAt({
411
+ storePath,
412
+ sessionKey: sessionKey,
413
+ });
414
+ log?.info?.(`[Session] previousTimestamp=${previousTimestamp}`);
415
+ // 处理附件:下载 fileUrl 到本地,并收集媒体信息
416
+ let mediaUrls = [];
417
+ let mediaTypes = [];
418
+ let bodyText = message.text;
419
+ if (message.attachments && message.attachments.length > 0) {
420
+ log?.info?.(`[Attachments] Downloading ${message.attachments.length} files...`);
421
+ // 并行下载所有附件
422
+ const downloadResults = await Promise.all(message.attachments.map(async (att) => {
423
+ const localPath = await downloadFileToLocal(att.url, accountId, message.from, log);
424
+ const mediaType = detectMediaType(att.url);
425
+ const typeLabel = getMediaTypeLabel(att.url);
426
+ return { att, localPath, mediaType, typeLabel };
427
+ }));
428
+ // 收集下载成功的文件信息(MediaUrls 由 SDK 用于显示 [media attached])
429
+ for (const { att, localPath, mediaType } of downloadResults) {
430
+ if (localPath) {
431
+ // 将绝对路径转为根相对路径(去掉 ~/.openclaw/ 前缀,转为 /.openclaw/... 形式)
432
+ // OpenClaw skill 系统会 block 绝对路径(C:\...)和 ~ 路径
433
+ // 兼容 Windows 路径格式(C:\Users\用户名\.openclaw\... 或 C:\Users\用户名.openclaw\...)
434
+ const homeDir = os.homedir().replace(/\\/g, '/').trim(); // 统一用正斜杠,去除尾随空格
435
+ const normalizedLocalPath = localPath.replace(/\\/g, '/');
436
+ const relativePath = normalizedLocalPath.startsWith(`${homeDir}/.openclaw/`)
437
+ ? `/${normalizedLocalPath.slice(homeDir.length + 1)}` // 用 / 开头表示根相对路径
438
+ : normalizedLocalPath;
439
+ mediaUrls.push(relativePath);
440
+ mediaTypes.push(mediaType);
441
+ log?.info?.(`[Attachments] Downloaded: ${relativePath}`);
442
+ }
443
+ else {
444
+ log?.warn?.(`[Attachments] Failed to download: ${att.url}`);
445
+ }
446
+ }
447
+ log?.info?.(`[Attachments] Downloaded ${mediaUrls.length}/${message.attachments.length} files`);
448
+ }
449
+ const fromLabel = message.to;
450
+ const body = pluginRuntime.channel.reply.formatInboundEnvelope({
451
+ channel: OPENCLAW_WORKCLAW_CHANNEL,
452
+ from: fromLabel,
453
+ timestamp: message.timestamp,
454
+ body: bodyText,
455
+ chatType: message.chatType,
456
+ sender: { name: message.to, id: message.to },
457
+ previousTimestamp,
458
+ envelope: envelopeOptions,
459
+ });
460
+ // 构建完整的 ctx
461
+ const finalized = pluginRuntime.channel.reply.finalizeInboundContext({
462
+ Body: body,
463
+ BodyForAgent: bodyText,
464
+ RawBody: JSON.stringify(message),
465
+ CommandBody: message.text,
466
+ From: message.from,
467
+ To: message.to,
468
+ SessionKey: sessionKey,
469
+ AccountId: accountId,
470
+ ChatType: message.chatType,
471
+ ConversationLabel: fromLabel,
472
+ Provider: OPENCLAW_WORKCLAW_CHANNEL,
473
+ Surface: 'openclaw-workclaw_dm',
474
+ MessageSid: message.messageId,
475
+ Timestamp: message.timestamp,
476
+ OriginatingChannel: OPENCLAW_WORKCLAW_CHANNEL,
477
+ OriginatingTo: message.to,
478
+ // 附件媒体信息(供 skill 读取本地文件)
479
+ MediaPath: mediaUrls[0] || undefined,
480
+ MediaUrl: mediaUrls[0] || undefined,
481
+ MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
482
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
483
+ });
484
+ // 记录 session(需要在 dispatchReplyWithBufferedBlockDispatcher 之前调用)
485
+ // 这样 OpenClaw 才能在工具执行时正确关联 session
486
+ if (sessionKey && recordSession) {
487
+ try {
488
+ await pluginRuntime.channel.session.recordInboundSession({
489
+ storePath,
490
+ sessionKey: finalized.SessionKey || sessionKey,
491
+ ctx: finalized,
492
+ updateLastRoute: {
493
+ sessionKey,
494
+ channel: OPENCLAW_WORKCLAW_CHANNEL,
495
+ to: message.to,
496
+ accountId,
497
+ },
498
+ onRecordError: (err) => {
499
+ log?.error?.(`[Session] recordInboundSession failed: ${String(err)}`);
500
+ },
501
+ });
502
+ log?.info?.(`[Session] Recorded session for key=${sessionKey}`);
503
+ }
504
+ catch (err) {
505
+ log?.warn?.(`[Session] recordInboundSession error: ${String(err)}`);
506
+ }
507
+ }
508
+ return finalized;
509
+ }