@workclaw/openclaw-workclaw 1.0.16 → 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 +12 -5
  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,43 +1,42 @@
1
- import { readFile, stat } from 'node:fs/promises'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
- import { fileURLToPath } from 'node:url'
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Agent, fetch as undiciFetch } from "undici";
5
6
 
6
7
  export function isLocalMediaSource(value: string): boolean {
7
- const trimmed = value.trim()
8
+ const trimmed = value.trim();
8
9
  return (
9
- trimmed.startsWith('/')
10
- || trimmed.startsWith('./')
11
- || trimmed.startsWith('../')
12
- || trimmed.startsWith('~')
13
- || trimmed.startsWith('file://')
14
- )
10
+ trimmed.startsWith("/") ||
11
+ trimmed.startsWith("./") ||
12
+ trimmed.startsWith("../") ||
13
+ trimmed.startsWith("~") ||
14
+ trimmed.startsWith("file://")
15
+ );
15
16
  }
16
17
 
17
18
  export function resolveLocalPath(input: string): string {
18
- if (input.startsWith('file://')) {
19
- return fileURLToPath(input)
19
+ if (input.startsWith("file://")) {
20
+ return fileURLToPath(input);
20
21
  }
21
- if (input.startsWith('~')) {
22
- return path.join(os.homedir(), input.slice(1))
22
+ if (input.startsWith("~")) {
23
+ return path.join(os.homedir(), input.slice(1));
23
24
  }
24
- return path.resolve(input)
25
+ return path.resolve(input);
25
26
  }
26
27
 
27
28
  function readResponseUrlPath(
28
29
  data: Record<string, unknown>,
29
30
  pathValue?: string,
30
31
  ): string | undefined {
31
- if (!pathValue)
32
- return undefined
33
- const parts = pathValue.split('.').filter(Boolean)
34
- let current: unknown = data
32
+ if (!pathValue) return undefined;
33
+ const parts = pathValue.split(".").filter(Boolean);
34
+ let current: unknown = data;
35
35
  for (const part of parts) {
36
- if (!current || typeof current !== 'object')
37
- return undefined
38
- current = (current as Record<string, unknown>)[part]
36
+ if (!current || typeof current !== "object") return undefined;
37
+ current = (current as Record<string, unknown>)[part];
39
38
  }
40
- return typeof current === 'string' ? current : undefined
39
+ return typeof current === "string" ? current : undefined;
41
40
  }
42
41
 
43
42
  function extractUploadedUrl(
@@ -45,110 +44,101 @@ function extractUploadedUrl(
45
44
  responseData: Record<string, unknown>,
46
45
  pathValue?: string,
47
46
  ): string | undefined {
48
- const fromPath = readResponseUrlPath(responseData, pathValue)
49
- if (fromPath)
50
- return fromPath
51
-
52
- const direct
53
- = (responseData.url as string | undefined)
54
- ?? (responseData.mediaUrl as string | undefined)
55
- if (typeof direct === 'string' && direct.trim())
56
- return direct
57
-
58
- const dataObj = responseData.data
59
- if (dataObj && typeof dataObj === 'object') {
60
- const dataUrl = (dataObj as Record<string, unknown>).url
61
- const dataMedia = (dataObj as Record<string, unknown>).mediaUrl
62
- if (typeof dataUrl === 'string' && dataUrl.trim())
63
- return dataUrl
64
- if (typeof dataMedia === 'string' && dataMedia.trim())
65
- return dataMedia
47
+ const fromPath = readResponseUrlPath(responseData, pathValue);
48
+ if (fromPath) return fromPath;
49
+
50
+ const direct =
51
+ (responseData.url as string | undefined) ??
52
+ (responseData.mediaUrl as string | undefined);
53
+ if (typeof direct === "string" && direct.trim()) return direct;
54
+
55
+ const dataObj = responseData.data;
56
+ if (dataObj && typeof dataObj === "object") {
57
+ const dataUrl = (dataObj as Record<string, unknown>).url;
58
+ const dataMedia = (dataObj as Record<string, unknown>).mediaUrl;
59
+ if (typeof dataUrl === "string" && dataUrl.trim()) return dataUrl;
60
+ if (typeof dataMedia === "string" && dataMedia.trim()) return dataMedia;
66
61
  }
67
62
 
68
- const resultObj = responseData.result
69
- if (resultObj && typeof resultObj === 'object') {
70
- const resultUrl = (resultObj as Record<string, unknown>).url
71
- const resultMedia = (resultObj as Record<string, unknown>).mediaUrl
72
- if (typeof resultUrl === 'string' && resultUrl.trim())
73
- return resultUrl
74
- if (typeof resultMedia === 'string' && resultMedia.trim())
75
- return resultMedia
63
+ const resultObj = responseData.result;
64
+ if (resultObj && typeof resultObj === "object") {
65
+ const resultUrl = (resultObj as Record<string, unknown>).url;
66
+ const resultMedia = (resultObj as Record<string, unknown>).mediaUrl;
67
+ if (typeof resultUrl === "string" && resultUrl.trim()) return resultUrl;
68
+ if (typeof resultMedia === "string" && resultMedia.trim()) return resultMedia;
76
69
  }
77
70
 
78
71
  if (responseText.trim() && /^https?:\/\//i.test(responseText.trim())) {
79
- return responseText.trim()
72
+ return responseText.trim();
80
73
  }
81
- return undefined
74
+ return undefined;
82
75
  }
83
76
 
84
77
  export interface UploadLocalMediaParams {
85
- uploadUrl: string
86
- filePath: string
87
- uploadFieldName?: string
88
- uploadHeaders?: Record<string, string>
89
- uploadFormFields?: Record<string, string | number | boolean>
90
- uploadResponseUrlPath?: string
91
- requestTimeout: number
92
- allowInsecureTls?: boolean
78
+ uploadUrl: string;
79
+ filePath: string;
80
+ uploadFieldName?: string;
81
+ uploadHeaders?: Record<string, string>;
82
+ uploadFormFields?: Record<string, string | number | boolean>;
83
+ uploadResponseUrlPath?: string;
84
+ requestTimeout: number;
85
+ allowInsecureTls?: boolean;
93
86
  }
94
87
 
95
88
  export async function uploadLocalMedia(params: UploadLocalMediaParams): Promise<string> {
96
- const stats = await stat(params.filePath)
89
+ const stats = await stat(params.filePath);
97
90
  if (!stats.isFile()) {
98
- throw new Error(`mediaUrl is not a file: ${params.filePath}`)
91
+ throw new Error(`mediaUrl is not a file: ${params.filePath}`);
99
92
  }
100
93
 
101
- const content = await readFile(params.filePath)
102
- const form = new FormData()
103
- const fieldName = params.uploadFieldName?.trim() || 'file'
104
- const fileName = path.basename(params.filePath)
105
- form.set(fieldName, new Blob([content], { type: 'application/octet-stream' }), fileName)
94
+ const content = await readFile(params.filePath);
95
+ const form = new FormData();
96
+ const fieldName = params.uploadFieldName?.trim() || "file";
97
+ const fileName = path.basename(params.filePath);
98
+ form.set(fieldName, new Blob([content], { type: "application/octet-stream" }), fileName);
106
99
 
107
100
  if (params.uploadFormFields) {
108
101
  for (const [key, value] of Object.entries(params.uploadFormFields)) {
109
- form.set(key, String(value))
102
+ form.set(key, String(value));
110
103
  }
111
104
  }
112
105
 
113
- const controller = new AbortController()
114
- const timeoutId = setTimeout(() => controller.abort(), params.requestTimeout)
106
+ const controller = new AbortController();
107
+ const timeoutId = setTimeout(() => controller.abort(), params.requestTimeout);
115
108
 
116
- let dispatcher: unknown
117
- let doFetch: any = globalThis.fetch.bind(globalThis)
109
+ let dispatcher: unknown = undefined;
110
+ let doFetch: any = globalThis.fetch.bind(globalThis);
118
111
 
119
112
  if (params.allowInsecureTls) {
120
113
  try {
121
- const { Agent, fetch: undiciFetchFn } = await import('undici')
122
- dispatcher = new Agent({ connect: { rejectUnauthorized: false } })
123
- doFetch = undiciFetchFn as any
124
- }
125
- catch (error) {
126
- const message = error instanceof Error ? error.message : String(error)
127
- throw new Error(`allowInsecureTls requires undici. ${message}`)
114
+ dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
115
+ doFetch = undiciFetch as any;
116
+ } catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ throw new Error(`allowInsecureTls requires undici. ${message}`);
128
119
  }
129
120
  }
130
121
 
131
122
  try {
132
123
  const response = await doFetch(params.uploadUrl, {
133
- method: 'POST',
124
+ method: "POST",
134
125
  headers: params.uploadHeaders ?? {},
135
126
  body: form,
136
127
  signal: controller.signal,
137
128
  ...(dispatcher ? { dispatcher } : {}),
138
- })
129
+ });
139
130
 
140
- const responseText = await response.text().catch(() => '')
131
+ const responseText = await response.text().catch(() => "");
141
132
  if (!response.ok) {
142
- throw new Error(`Upload failed: ${response.status} ${responseText}`)
133
+ throw new Error(`Upload failed: ${response.status} ${responseText}`);
143
134
  }
144
135
 
145
- let data: Record<string, unknown> = {}
136
+ let data: Record<string, unknown> = {};
146
137
  if (responseText) {
147
138
  try {
148
- data = JSON.parse(responseText) as Record<string, unknown>
149
- }
150
- catch {
151
- data = {}
139
+ data = JSON.parse(responseText) as Record<string, unknown>;
140
+ } catch {
141
+ data = {};
152
142
  }
153
143
  }
154
144
 
@@ -156,13 +146,12 @@ export async function uploadLocalMedia(params: UploadLocalMediaParams): Promise<
156
146
  responseText,
157
147
  data,
158
148
  params.uploadResponseUrlPath,
159
- )
149
+ );
160
150
  if (!uploadedUrl) {
161
- throw new Error('Upload response missing file URL')
151
+ throw new Error("Upload response missing file URL");
162
152
  }
163
- return uploadedUrl
164
- }
165
- finally {
166
- clearTimeout(timeoutId)
153
+ return uploadedUrl;
154
+ } finally {
155
+ clearTimeout(timeoutId);
167
156
  }
168
157
  }
@@ -1,12 +1,13 @@
1
- import type { OpenClawConfig } from 'openclaw/plugin-sdk'
2
- import type { OpenclawWorkclawConfig, OpenclawWorkclawSendResult } from '../types.js'
3
- import { resolveAccountByUserIdAndAgentId, resolveOpenclawWorkclawAccount } from '../accounts.js'
4
-
5
- import { isLocalMediaSource, resolveLocalPath, uploadLocalMedia } from '../media/upload.js'
6
- import { getOpenclawWorkclawLogger } from '../runtime.js'
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import type { WorkclawSendResult, WorkclawConfig } from "../types.js";
3
+ import { resolveWorkclawAccountWithCache } from "../accounts.js";
4
+ import { resolveAccountByUserIdAndAgentId } from "../accounts.js";
5
+ import { isLocalMediaSource, resolveLocalPath, uploadLocalMedia } from "../media/upload.js";
7
6
  import {
8
- sendOpenclawWorkclawOutboundMessage,
9
- } from './workclaw-sender.js'
7
+ sendWorkclawOutboundMessage,
8
+ } from "./workclaw-sender.js";
9
+ import { getWorkclawLogger, setLastOutboundAt } from "../runtime.js";
10
+ import { stat } from "node:fs/promises";
10
11
 
11
12
  /**
12
13
  * 根据 target (userId) 和可选的 agentId 查找匹配的账户
@@ -28,15 +29,15 @@ function findAccountIdByTarget(
28
29
  return undefined
29
30
  }
30
31
 
31
- export interface SendOpenclawWorkclawMessageParams {
32
- cfg: OpenClawConfig
33
- to: string
34
- text: string
35
- mediaUrl?: string
36
- replyToMessageId?: string
37
- accountId?: string
38
- openConversationId?: string
39
- agentId?: string | number
32
+ export type SendWorkclawMessageParams = {
33
+ cfg: OpenClawConfig;
34
+ to: string;
35
+ text: string;
36
+ mediaUrl?: string;
37
+ replyToMessageId?: string;
38
+ accountId?: string;
39
+ openConversationId?: string;
40
+ agentId?: string | number;
40
41
  /**
41
42
  * 消息类型:
42
43
  * - "reply": 回复消息(用户触发)
@@ -49,7 +50,7 @@ export interface SendOpenclawWorkclawMessageParams {
49
50
 
50
51
  async function resolveMediaUrl(
51
52
  mediaUrl: string | undefined,
52
- config: OpenclawWorkclawConfig,
53
+ config: WorkclawConfig,
53
54
  ): Promise<string | undefined> {
54
55
  if (!mediaUrl)
55
56
  return undefined
@@ -63,10 +64,9 @@ async function resolveMediaUrl(
63
64
 
64
65
  const filePath = resolveLocalPath(mediaUrl)
65
66
 
66
- if (typeof config.mediaMaxMb === 'number' && config.mediaMaxMb > 0) {
67
- const { stat } = await import('node:fs/promises')
68
- const stats = await stat(filePath)
69
- const maxBytes = config.mediaMaxMb * 1024 * 1024
67
+ if (typeof config.mediaMaxMb === "number" && config.mediaMaxMb > 0) {
68
+ const stats = await stat(filePath);
69
+ const maxBytes = config.mediaMaxMb * 1024 * 1024;
70
70
  if (stats.size > maxBytes) {
71
71
  throw new Error(`mediaUrl exceeds limit (${config.mediaMaxMb} MB)`)
72
72
  }
@@ -86,11 +86,11 @@ async function resolveMediaUrl(
86
86
  })
87
87
  }
88
88
 
89
- export async function sendMessageOpenclawWorkclaw(
90
- params: SendOpenclawWorkclawMessageParams,
91
- ): Promise<OpenclawWorkclawSendResult> {
92
- const { cfg, to, text, mediaUrl, replyToMessageId, accountId, openConversationId, agentId, messageType, msgType, last }
93
- = params
89
+ export async function sendMessageWorkclaw(
90
+ params: SendWorkclawMessageParams,
91
+ ): Promise<WorkclawSendResult> {
92
+ const { cfg, to, text, mediaUrl, replyToMessageId, accountId, openConversationId, agentId, messageType, msgType, last } =
93
+ params;
94
94
 
95
95
  // 如果没有指定 messageType,根据是否有 replyToMessageId 来判断
96
96
  // - 有 replyToMessageId: 回复消息
@@ -106,30 +106,26 @@ export async function sendMessageOpenclawWorkclaw(
106
106
  }
107
107
  }
108
108
 
109
- if (!resolvedAccountId) {
110
- throw new Error('无法确定账户ID')
111
- }
112
-
113
- const account = resolveOpenclawWorkclawAccount({ cfg, accountId: resolvedAccountId })
109
+ const account = resolveWorkclawAccountWithCache({ cfg, accountId: resolvedAccountId });
114
110
  if (!account.configured) {
115
- throw new Error(`OpenclawWorkclaw account "${account.accountId}" not configured`)
111
+ throw new Error(`Workclaw account "${account.accountId}" not configured`);
116
112
  }
117
113
 
118
114
  const resolvedMediaUrl = await resolveMediaUrl(mediaUrl, account.config)
119
115
 
120
116
  // 优先使用传入的 openConversationId,其次从 cfg 中直接读取(绕过 account.config 快照问题)
121
117
  // 这样可以获取到运行时动态保存的 openConversationId
122
- interface ChannelWithAccounts { accounts?: Record<string, any>, openConversationId?: string }
123
- const cfgChannel = (cfg.channels?.['openclaw-workclaw'] as unknown as ChannelWithAccounts) ?? {}
124
- const cfgAccounts = cfgChannel.accounts ?? {}
125
- const cfgAccount = cfgAccounts[resolvedAccountId] ?? {}
126
- const cfgOpenConversationId = cfgAccount?.openConversationId ?? cfgChannel?.openConversationId
127
- const effectiveOpenConversationId = openConversationId ?? cfgOpenConversationId
128
- getOpenclawWorkclawLogger().info(`sendMessageOpenclawWorkclaw - resolvedAccountId: ${resolvedAccountId}, openConversationId param: ${openConversationId}, cfgOpenConversationId: ${cfgOpenConversationId}, effectiveOpenConversationId: ${effectiveOpenConversationId}, messageType: ${resolvedMessageType}, replyToMessageId: ${replyToMessageId}`)
118
+ type ChannelWithAccounts = { accounts?: Record<string, any>; openConversationId?: string };
119
+ const cfgChannel = (cfg.channels?.['openclaw-workclaw'] as unknown as ChannelWithAccounts) ?? {};
120
+ const cfgAccounts = cfgChannel.accounts ?? {};
121
+ const cfgAccount = cfgAccounts[resolvedAccountId] ?? {};
122
+ const cfgOpenConversationId = cfgAccount?.openConversationId ?? cfgChannel?.openConversationId;
123
+ const effectiveOpenConversationId = openConversationId ?? cfgOpenConversationId;
124
+ getWorkclawLogger().info(`sendMessageWorkclaw - resolvedAccountId: ${resolvedAccountId}, openConversationId param: ${openConversationId}, cfgOpenConversationId: ${cfgOpenConversationId}, effectiveOpenConversationId: ${effectiveOpenConversationId}, messageType: ${resolvedMessageType}, replyToMessageId: ${replyToMessageId}`);
129
125
 
130
126
  // 使用 appKey 作为缓存键,让所有账户共享同一个 token
131
- const tokenCacheKey = account.config.appKey || account.accountId
132
- const result = await sendOpenclawWorkclawOutboundMessage({
127
+ const tokenCacheKey = account.config.appKey || account.accountId;
128
+ const result = await sendWorkclawOutboundMessage({
133
129
  cacheKey: tokenCacheKey,
134
130
  to,
135
131
  text,
@@ -141,17 +137,23 @@ export async function sendMessageOpenclawWorkclaw(
141
137
  config: account.config,
142
138
  messageType: resolvedMessageType,
143
139
  last,
144
- })
140
+ });
141
+
142
+ // 更新上次出站时间
143
+ if (resolvedAccountId) {
144
+ setLastOutboundAt(resolvedAccountId, Date.now());
145
+ }
146
+
145
147
  return {
146
148
  messageId: result.messageId,
147
149
  chatId: to,
148
150
  }
149
151
  }
150
152
 
151
- export async function getMessageOpenclawWorkclaw(_params: {
152
- cfg: OpenClawConfig
153
- messageId: string
154
- accountId?: string
153
+ export async function getMessageWorkclaw(_params: {
154
+ cfg: OpenClawConfig;
155
+ messageId: string;
156
+ accountId?: string;
155
157
  }): Promise<null> {
156
158
  return null
157
159
  }
@@ -160,10 +162,10 @@ export async function getMessageOpenclawWorkclaw(_params: {
160
162
  * 发送主动推送消息(定时任务使用)
161
163
  * 使用 pushEndpoint 配置的端口
162
164
  */
163
- export async function sendPushMessageOpenclawWorkclaw(
164
- params: Omit<SendOpenclawWorkclawMessageParams, 'messageType'>,
165
- ): Promise<OpenclawWorkclawSendResult> {
166
- return sendMessageOpenclawWorkclaw({
165
+ export async function sendPushMessageWorkclaw(
166
+ params: Omit<SendWorkclawMessageParams, "messageType">,
167
+ ): Promise<WorkclawSendResult> {
168
+ return sendMessageWorkclaw({
167
169
  ...params,
168
170
  messageType: 'push',
169
171
  })
@@ -173,10 +175,10 @@ export async function sendPushMessageOpenclawWorkclaw(
173
175
  * 发送回复消息(用户触发)
174
176
  * 使用 replyEndpoint 配置的端口
175
177
  */
176
- export async function sendReplyMessageOpenclawWorkclaw(
177
- params: Omit<SendOpenclawWorkclawMessageParams, 'messageType'>,
178
- ): Promise<OpenclawWorkclawSendResult> {
179
- return sendMessageOpenclawWorkclaw({
178
+ export async function sendReplyMessageWorkclaw(
179
+ params: Omit<SendWorkclawMessageParams, "messageType">,
180
+ ): Promise<WorkclawSendResult> {
181
+ return sendMessageWorkclaw({
180
182
  ...params,
181
183
  messageType: 'reply',
182
184
  })