@workclaw/openclaw-workclaw 1.0.17 → 1.0.18

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 (51) hide show
  1. package/README.md +21 -1
  2. package/index.ts +210 -210
  3. package/openclaw.plugin.json +1 -0
  4. package/package.json +11 -4
  5. package/setup-entry.ts +6 -0
  6. package/skills/openclaw-workclaw-cron/SKILL.md +45 -28
  7. package/src/accounts.ts +62 -37
  8. package/src/api/accounts-api.ts +88 -89
  9. package/src/api/prompts-api.ts +70 -77
  10. package/src/api/session-api.ts +99 -108
  11. package/src/api/skills-api.ts +35 -37
  12. package/src/api/workspace.ts +27 -29
  13. package/src/channel.ts +200 -202
  14. package/src/config-schema.ts +9 -9
  15. package/src/connection/workclaw-client.ts +554 -567
  16. package/src/gateway/agent-handlers.ts +392 -426
  17. package/src/gateway/config-writer.ts +228 -243
  18. package/src/gateway/message-context.ts +534 -362
  19. package/src/gateway/message-dispatcher.ts +529 -489
  20. package/src/gateway/reconnect.ts +217 -113
  21. package/src/gateway/skills-handler.ts +408 -472
  22. package/src/gateway/skills-list-handler.ts +9 -9
  23. package/src/gateway/tools-list-handler.ts +70 -72
  24. package/src/gateway/workclaw-gateway.ts +328 -486
  25. package/src/media/upload.ts +83 -94
  26. package/src/outbound/index.ts +57 -55
  27. package/src/outbound/workclaw-sender.ts +134 -133
  28. package/src/runtime.ts +291 -194
  29. package/src/send.ts +1 -1
  30. package/src/tools/openclaw-workclaw-cron/api/index.ts +6 -6
  31. package/src/tools/openclaw-workclaw-cron/src/add/params.ts +20 -19
  32. package/src/tools/openclaw-workclaw-cron/src/add/sync.ts +2 -2
  33. package/src/tools/openclaw-workclaw-cron/src/disable/params.ts +1 -1
  34. package/src/tools/openclaw-workclaw-cron/src/disable/sync.ts +3 -3
  35. package/src/tools/openclaw-workclaw-cron/src/enable/params.ts +1 -1
  36. package/src/tools/openclaw-workclaw-cron/src/enable/sync.ts +3 -3
  37. package/src/tools/openclaw-workclaw-cron/src/notify/sync.ts +2 -2
  38. package/src/tools/openclaw-workclaw-cron/src/remove/params.ts +1 -1
  39. package/src/tools/openclaw-workclaw-cron/src/remove/sync.ts +3 -3
  40. package/src/tools/openclaw-workclaw-cron/src/update/params.ts +195 -197
  41. package/src/tools/openclaw-workclaw-cron/src/update/sync.ts +4 -4
  42. package/src/tools/openclaw-workclaw-system/src/get/index.ts +2 -2
  43. package/src/tools/openclaw-workclaw-system/src/token/index.ts +4 -4
  44. package/src/types.ts +38 -40
  45. package/src/utils/content.ts +16 -21
  46. package/tests/accounts.test.ts +285 -0
  47. package/tests/message-context.test.ts +313 -0
  48. package/tests/reconnect.test.ts +257 -0
  49. package/tests/workclaw-client.test.ts +112 -0
  50. package/tsconfig.json +8 -5
  51. package/vitest.config.ts +8 -0
@@ -1,275 +1,408 @@
1
- import type { PluginRuntime } from 'openclaw/plugin-sdk'
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
+ }
2
16
 
3
17
  /** Channel/plugin name constant */
4
- export const OPENCLAW_WORKCLAW_CHANNEL = 'openclaw-workclaw'
18
+ export const OPENCLAW_WORKCLAW_CHANNEL = "openclaw-workclaw";
5
19
 
6
20
  export interface InboundMessage {
7
- text: string
8
- to: string
9
- from: string
10
- chatType: 'dm' | 'group'
11
- messageId: string
12
- rawPayload: string
13
- timestamp: number
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 列表
14
29
  }
15
30
 
16
31
  export interface InboundContextPayload {
17
- [key: string]: unknown
18
- Body: string
19
- BodyForAgent: string
20
- RawBody: string
21
- From: string
22
- To: string
23
- ChatType: 'dm' | 'group'
24
- Provider: string
25
- Surface: string
26
- Timestamp: number
27
- AccountId: string
28
- AgentId?: string
29
- MessageSid?: string
30
- OriginatingChannel: string
31
- OriginatingTo: string
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;
32
47
  }
33
48
 
34
- export type WorkClawMessageType
35
- = | 'agent_message' // 正常的业务消息
36
- | 'ping' // 需要回复 pong 的消息
37
- | 'disconnect' // 断开连接的消息
38
- | 'agent_created' // 创建账户事件
39
- | 'agent_updated' // 更新账户事件
40
- | 'agent_deleted' // 删除账户事件
41
- | 'tools_list' // 获取工具列表事件
42
- | 'skills_list' // 获取 skills 列表事件
43
- | 'skills_event' // skills 相关事件(create/update/delete/list/get/invoke)
44
- | 'init_agent' // 初始化智能体事件
45
- | 'ignored' // 忽略的消息(未知类型)
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"; // 忽略的消息(未知类型)
46
61
 
47
62
  export interface ParseWorkClawResult {
48
- type: WorkClawMessageType
49
- message?: {
50
- text: string
51
- userId: string
52
- openConversationId: string
53
- messageId: string
54
- agentId?: string | number
55
- }
56
- pongData?: any // 用于回复 pong 的数据
57
- eventData?: any // 事件数据(用于 AGENT_CREATED/UPDATED/DELETED)
58
- skillsEvent?: {
59
- // skills 事件数据
60
- topic: string // skills/create, skills/update, skills/delete, skills/list, skills/get, skills/invoke
61
- requestId: string
62
- callbackUrl: string
63
- authToken?: string
64
- data: any
65
- }
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 事件)
66
74
  }
67
75
 
68
76
  export function parseInboundMessage(data: string, allowRawJsonPayload?: boolean): Partial<InboundMessage> {
69
- let input: any = {}
70
- try {
71
- input = data ? JSON.parse(data) : {}
72
- }
73
- catch {
74
- // Not JSON, treat as plain text
75
- }
76
-
77
- const text
78
- = typeof input.text === 'string'
79
- ? input.text
80
- : typeof input.message === 'string'
81
- ? input.message
82
- : typeof input.body === 'string'
83
- ? input.body
84
- : data
85
-
86
- const to = String(input.to ?? input.chatId ?? input.target ?? '')
87
- const from = String(input.from ?? input.senderId ?? input.userId ?? '')
88
- const chatType = String(input.chatType || '').toLowerCase() === 'group' ? 'group' : 'dm'
89
- const messageId = String(input.messageId ?? input.id ?? '')
90
- const rawPayload = allowRawJsonPayload ? JSON.stringify(input ?? {}) : data
91
-
92
- return {
93
- text,
94
- to,
95
- from,
96
- chatType,
97
- messageId,
98
- rawPayload,
99
- }
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
+ };
100
107
  }
101
108
 
102
109
  export function parseWorkClawMessage(data: string, log?: {
103
- info?: (msg: string) => void
104
- warn?: (msg: string) => void
105
- error?: (msg: string) => void
110
+ info?: (msg: string) => void;
111
+ warn?: (msg: string) => void;
112
+ error?: (msg: string) => void;
106
113
  }): ParseWorkClawResult | null {
107
- // {"specVersion":"1.0","type":"EVENT","metadata":{"time":"1773749163831","event":"INIT_AGENT","contentType":"application/json"},"data":"\"12557533957824965\""}
108
- let input: any = {}
109
- try {
110
- input = data ? JSON.parse(data) : {}
111
- }
112
- catch {
113
- log?.info?.(`Failed to parse message data=${data}`)
114
- return null
115
- }
116
-
117
- // Handle plain text ping
118
- if (typeof input === 'string' && input.toLowerCase() === 'ping') {
119
- return {
120
- type: 'ping',
121
- pongData: input,
122
- }
123
- }
124
-
125
- const envelope = input && typeof input === 'object' ? input : { data: input }
126
- const topic = envelope?.metadata?.topic
127
- const type = String(envelope?.type || '').trim().toUpperCase()
128
- const topicLower = String(topic || '').trim().toLowerCase()
129
-
130
- if (type === 'INIT_AGENT') {
131
- let parsedData = envelope?.data
132
- if (typeof parsedData === 'string') {
133
- try {
134
- parsedData = JSON.parse(parsedData)
135
- }
136
- catch {
137
- // 解析失败,保持原样
138
- }
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;
139
121
  }
140
122
 
141
- return {
142
- type: 'init_agent',
143
- eventData: parsedData,
123
+ // Handle plain text ping
124
+ if (typeof input === "string" && input.toLowerCase() === "ping") {
125
+ return {
126
+ type: "ping",
127
+ pongData: input,
128
+ };
144
129
  }
145
- }
146
-
147
- // Handle SYSTEM messages
148
- if (type === 'SYSTEM') {
149
- if (topicLower === 'ping') {
150
- return {
151
- type: 'ping',
152
- pongData: envelope?.data,
153
- }
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
+ };
154
150
  }
155
- if (topicLower === 'disconnect') {
156
- return {
157
- type: 'disconnect',
158
- }
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
+ };
159
169
  }
160
- // Unknown system message type, ignore
161
- return {
162
- type: 'ignored',
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
+ };
163
240
  }
164
- }
165
241
 
166
- // Handle EVENT messages (AGENT_CREATED, AGENT_UPDATED, AGENT_DELETED, TOOLS_LIST, SKILLS_LIST, INIT_AGENT)
167
- if (type === 'EVENT') {
168
- const eventType = String(envelope?.metadata?.event || '').trim().toUpperCase()
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
+ }
169
248
 
170
249
  // 解析 data 字段(可能是 JSON 字符串)
171
- let parsedData = envelope?.data
172
- if (typeof parsedData === 'string') {
173
- try {
174
- parsedData = JSON.parse(parsedData)
175
- }
176
- catch {
177
- // 解析失败,保持原样
178
- }
250
+ let payload = envelope?.data || {};
251
+ if (typeof payload === "string") {
252
+ try {
253
+ payload = JSON.parse(payload);
254
+ } catch (e) {
255
+ // 解析失败,保持原样
256
+ }
179
257
  }
180
258
 
181
- if (eventType === 'AGENT_CREATED') {
182
- return {
183
- type: 'agent_created',
184
- eventData: parsedData,
185
- }
186
- }
187
- if (eventType === 'AGENT_UPDATED') {
188
- return {
189
- type: 'agent_updated',
190
- eventData: parsedData,
191
- }
192
- }
193
- if (eventType === 'AGENT_DELETED') {
194
- return {
195
- type: 'agent_deleted',
196
- eventData: parsedData,
197
- }
198
- }
199
- if (eventType === 'TOOLS_LIST') {
200
- return {
201
- type: 'tools_list',
202
- eventData: parsedData,
203
- }
204
- }
205
- if (eventType === 'SKILLS_LIST') {
206
- return {
207
- type: 'skills_list',
208
- eventData: parsedData,
209
- }
210
- }
211
- if (eventType === 'INIT_AGENT') {
212
- return {
213
- type: 'init_agent',
214
- eventData: parsedData,
215
- }
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));
216
271
  }
217
272
 
218
- if (eventType === 'SKILL_DO_REMIND') {
219
- const eventTopic = String(parsedData?.dotype || '').toLowerCase()
220
- return {
221
- type: 'skills_event',
222
- eventData: {
223
- topic: `skills/${eventTopic}`,
224
- requestId: '',
225
- callbackUrl: '',
226
- authToken: '',
227
- data: parsedData,
228
- },
229
- }
230
- }
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
+ }));
231
280
 
232
- // Unknown event type, ignore
233
281
  return {
234
- type: 'ignored',
235
- }
236
- }
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
+ }
237
293
 
238
- // Only process CALLBACK type with AGENT_MESSAGE topic
239
- if (type !== 'CALLBACK' || String(topic || '').trim().toUpperCase() !== 'AGENT_MESSAGE') {
240
- return {
241
- type: 'ignored',
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";
242
303
  }
243
- }
244
-
245
- // 解析 data 字段(可能是 JSON 字符串)
246
- let payload = envelope?.data || {}
247
- if (typeof payload === 'string') {
248
- try {
249
- payload = JSON.parse(payload)
304
+ if (lower.endsWith(".mp3") || lower.endsWith(".wav") || lower.endsWith(".amr")
305
+ || lower.endsWith(".m4a") || lower.endsWith(".ogg")) {
306
+ return "audio";
250
307
  }
251
- catch {
252
- // 解析失败,保持原样
308
+ if (lower.endsWith(".mp4") || lower.endsWith(".avi") || lower.endsWith(".mov")
309
+ || lower.endsWith(".mkv") || lower.endsWith(".webm")) {
310
+ return "video";
253
311
  }
254
- }
255
-
256
- const text
257
- = typeof payload?.message?.content === 'string'
258
- ? payload.message.content
259
- : typeof payload?.message === 'string'
260
- ? payload.message
261
- : ''
262
-
263
- return {
264
- type: 'agent_message',
265
- message: {
266
- text,
267
- userId: String(payload?.userId ?? ''),
268
- openConversationId: String(payload?.conversationId ?? ''),
269
- messageId: String(payload?.msgId ?? ''),
270
- agentId: payload?.agentId,
271
- },
272
- }
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
+ });
273
406
  }
274
407
 
275
408
  /**
@@ -281,142 +414,181 @@ export function parseWorkClawMessage(data: string, log?: {
281
414
  *
282
415
  * @param message - The inbound message
283
416
  * @param accountId - Account ID
284
- * @param config - Account config (OpenclawWorkclawConfig)
417
+ * @param config - Account config (WorkclawConfig)
285
418
  * @param agentId - Target agent ID (optional)
286
419
  * @param pluginRuntime - OpenClaw plugin runtime (optional, enables full session handling)
287
420
  * @param recordSession - Whether to record session (default true)
288
- * @param log 日志对象(可选)
289
- * @param {Function} [log.info] - 信息日志函数
290
- * @param {Function} [log.error] - 错误日志函数
291
- * @param {Function} [log.warn] - 警告日志函数
421
+ * @param log - Optional logger
292
422
  */
293
423
  export async function buildInboundContext(
294
- message: InboundMessage,
295
- accountId: string,
296
- config: any,
297
- agentId?: string,
298
- pluginRuntime?: PluginRuntime,
299
- recordSession: boolean = true,
300
- log?: { info?: (msg: string) => void, warn?: (msg: string) => void, error?: (msg: string) => void },
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 },
301
431
  ): Promise<InboundContextPayload> {
302
- // 尝试从 rawPayload 中提取已有的 sessionKey(新建话题时 openclaw 会生成新的 session)
303
- let existingSessionKey: string | undefined
304
- try {
305
- const rawPayload = JSON.parse(message.rawPayload)
306
- existingSessionKey = rawPayload.sessionKey
307
- }
308
- catch {
309
- // ignore
310
- }
311
-
312
- const peerId = message.from
313
- const computedSessionKey = existingSessionKey
314
- || (agentId && agentId !== 'main'
315
- ? `agent:${agentId}:openclaw-workclaw:${accountId}:direct:${peerId}`
316
- : undefined)
317
-
318
- const sessionKey = computedSessionKey || (agentId && agentId !== 'main'
319
- ? `agent:${agentId}:openclaw-workclaw:${accountId}:direct:${peerId}`
320
- : undefined)
321
-
322
- // Basic context (used as base for both paths)
323
- const result: InboundContextPayload = {
324
- Body: message.text,
325
- BodyForAgent: message.rawPayload || message.text,
326
- RawBody: JSON.stringify(message),
327
- From: message.from,
328
- To: message.to,
329
- ChatType: message.chatType,
330
- Provider: OPENCLAW_WORKCLAW_CHANNEL,
331
- Surface: OPENCLAW_WORKCLAW_CHANNEL,
332
- Timestamp: message.timestamp,
333
- AccountId: accountId,
334
- AgentId: agentId,
335
- MessageSid: message.messageId,
336
- OriginatingChannel: OPENCLAW_WORKCLAW_CHANNEL,
337
- OriginatingTo: message.to,
338
- }
339
-
340
- if (sessionKey) {
341
- result.SessionKey = sessionKey
342
- }
343
-
344
- // If no pluginRuntime provided, return basic context without session recording
345
- if (!pluginRuntime) {
346
- return result
347
- }
348
-
349
- // Full session handling path
350
- const storePath = pluginRuntime.channel.session.resolveStorePath(config.session?.store, { agentId })
351
- log?.info?.(`[Session] storePath=${storePath}, agentId=${agentId}, sessionKey=${sessionKey}`)
352
-
353
- // 获取 envelope 格式选项
354
- const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(config)
355
-
356
- // 获取上次的 session 更新时间
357
- const previousTimestamp = pluginRuntime.channel.session.readSessionUpdatedAt({
358
- storePath,
359
- sessionKey: sessionKey!,
360
- })
361
- log?.info?.(`[Session] previousTimestamp=${previousTimestamp}`)
362
-
363
- // 格式化 envelope(Body 用于日志/历史,BodyForAgent 用于 AI)
364
- const fromLabel = message.to
365
- const body = pluginRuntime.channel.reply.formatInboundEnvelope({
366
- channel: OPENCLAW_WORKCLAW_CHANNEL,
367
- from: fromLabel,
368
- timestamp: message.timestamp,
369
- body: message.text,
370
- chatType: message.chatType,
371
- sender: { name: message.to, id: message.to },
372
- previousTimestamp,
373
- envelope: envelopeOptions,
374
- })
375
-
376
- // 构建完整的 ctx
377
- const finalized = pluginRuntime.channel.reply.finalizeInboundContext({
378
- Body: body,
379
- BodyForAgent: message.text,
380
- RawBody: JSON.stringify(message),
381
- CommandBody: message.text,
382
- From: message.from,
383
- To: message.to,
384
- SessionKey: sessionKey,
385
- AccountId: accountId,
386
- ChatType: message.chatType,
387
- ConversationLabel: fromLabel,
388
- Provider: OPENCLAW_WORKCLAW_CHANNEL,
389
- Surface: 'openclaw-workclaw_dm',
390
- MessageSid: message.messageId,
391
- Timestamp: message.timestamp,
392
- OriginatingChannel: OPENCLAW_WORKCLAW_CHANNEL,
393
- OriginatingTo: message.to,
394
- })
395
-
396
- // 记录 session(需要在 dispatchReplyWithBufferedBlockDispatcher 之前调用)
397
- // 这样 OpenClaw 才能在工具执行时正确关联 session
398
- if (sessionKey && recordSession) {
432
+ // 尝试从 rawPayload 中提取已有的 sessionKey(新建话题时 openclaw 会生成新的 session)
433
+ let existingSessionKey: string | undefined;
399
434
  try {
400
- await pluginRuntime.channel.session.recordInboundSession({
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({
401
488
  storePath,
402
- sessionKey: finalized.SessionKey || sessionKey,
403
- ctx: finalized,
404
- updateLastRoute: {
405
- sessionKey,
406
- channel: OPENCLAW_WORKCLAW_CHANNEL,
407
- to: message.to,
408
- accountId,
409
- },
410
- onRecordError: (err: unknown) => {
411
- log?.error?.(`[Session] recordInboundSession failed: ${String(err)}`)
412
- },
413
- })
414
- log?.info?.(`[Session] Recorded session for key=${sessionKey}`)
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`);
415
530
  }
416
- catch (err) {
417
- log?.warn?.(`[Session] recordInboundSession error: ${String(err)}`)
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
+ }
418
590
  }
419
- }
420
591
 
421
- return finalized
592
+ return finalized;
422
593
  }
594
+