@workclaw/openclaw-workclaw 1.0.20 → 1.0.202

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 (53) hide show
  1. package/api.ts +3 -0
  2. package/index.ts +326 -0
  3. package/package.json +3 -11
  4. package/setup-entry.ts +13 -0
  5. package/src/accounts.ts +360 -0
  6. package/src/api/accounts-api.ts +156 -0
  7. package/src/api/prompts-api.ts +122 -0
  8. package/src/api/session-api.ts +246 -0
  9. package/src/api/skills-api.ts +74 -0
  10. package/src/api/workspace.ts +45 -0
  11. package/src/channel.ts +226 -0
  12. package/src/config-schema.ts +62 -0
  13. package/src/connection/workclaw-client.ts +618 -0
  14. package/src/gateway/agent-handlers.ts +551 -0
  15. package/src/gateway/config-writer.ts +378 -0
  16. package/src/gateway/cron-tasks-handler.ts +230 -0
  17. package/src/gateway/message-context.ts +645 -0
  18. package/src/gateway/message-dispatcher.ts +688 -0
  19. package/src/gateway/reconnect.ts +260 -0
  20. package/src/gateway/skills-handler.ts +805 -0
  21. package/src/gateway/skills-list-handler.ts +332 -0
  22. package/src/gateway/tools-list-handler.ts +161 -0
  23. package/src/gateway/workclaw-gateway.ts +305 -0
  24. package/src/media/upload.ts +168 -0
  25. package/src/outbound/index.ts +191 -0
  26. package/src/outbound/workclaw-sender.ts +161 -0
  27. package/src/runtime.ts +520 -0
  28. package/src/secret-contract-api.ts +4 -0
  29. package/src/send.ts +1 -0
  30. package/src/setup-api.ts +3 -0
  31. package/src/setup-core.ts +25 -0
  32. package/src/setup-surface.ts +499 -0
  33. package/src/tools/openclaw-workclaw-cron/api/index.ts +326 -0
  34. package/src/tools/openclaw-workclaw-cron/index.ts +39 -0
  35. package/src/tools/openclaw-workclaw-cron/src/add/params.ts +177 -0
  36. package/src/tools/openclaw-workclaw-cron/src/add/sync.ts +188 -0
  37. package/src/tools/openclaw-workclaw-cron/src/disable/params.ts +100 -0
  38. package/src/tools/openclaw-workclaw-cron/src/disable/sync.ts +127 -0
  39. package/src/tools/openclaw-workclaw-cron/src/enable/params.ts +100 -0
  40. package/src/tools/openclaw-workclaw-cron/src/enable/sync.ts +127 -0
  41. package/src/tools/openclaw-workclaw-cron/src/notify/sync.ts +148 -0
  42. package/src/tools/openclaw-workclaw-cron/src/remove/params.ts +109 -0
  43. package/src/tools/openclaw-workclaw-cron/src/remove/sync.ts +127 -0
  44. package/src/tools/openclaw-workclaw-cron/src/update/params.ts +195 -0
  45. package/src/tools/openclaw-workclaw-cron/src/update/sync.ts +161 -0
  46. package/src/tools/openclaw-workclaw-cron/types/index.ts +55 -0
  47. package/src/tools/openclaw-workclaw-cron/utils/index.ts +141 -0
  48. package/src/tools/openclaw-workclaw-system/index.ts +17 -0
  49. package/src/tools/openclaw-workclaw-system/src/get/index.ts +77 -0
  50. package/src/tools/openclaw-workclaw-system/src/token/index.ts +93 -0
  51. package/src/types.ts +52 -0
  52. package/src/utils/content.ts +40 -0
  53. package/tsconfig.json +34 -0
@@ -0,0 +1,305 @@
1
+ import type { ResolvedWorkclawAccount, WorkclawAccountConfig, WorkClawBaseConfig, WorkclawConfig } from '../types.js'
2
+ import type { MessageDispatcherContext } from './message-dispatcher.js'
3
+ import { buildAccountMap, resolveAccountByUserIdAndAgentId } from '../accounts.js'
4
+ import {
5
+ clearReconnectScheduler,
6
+ clearWorkclawConnectionConfig,
7
+ clearWorkclawWsConnection,
8
+ createLogger,
9
+ finishConnecting,
10
+ getAllDispatchersByAppKey,
11
+ getAppKeyByAccountId,
12
+ getDispatcherByAppKeyAndAccountId,
13
+ getReconnectScheduler,
14
+ getWorkclawWsConnection,
15
+ registerAccountContext,
16
+ setReconnectScheduler,
17
+ setWorkclawConnectionConfig,
18
+ setWorkclawLoggerFromContext,
19
+ tryStartConnecting,
20
+ unregisterAccountContext,
21
+
22
+ } from '../runtime.js'
23
+ import { parseWorkClawMessage } from './message-context.js'
24
+ import { dispatchWorkclawMessage } from './message-dispatcher.js'
25
+ import { createReconnectScheduler } from './reconnect.js'
26
+
27
+ export interface WorkclawGatewayOptions {
28
+ accountId: string
29
+ account: ResolvedWorkclawAccount
30
+ cfg: any
31
+ log?: {
32
+ info?: (msg: string) => void
33
+ warn?: (msg: string) => void
34
+ error?: (msg: string) => void
35
+ debug?: (msg: string) => void
36
+ }
37
+ }
38
+
39
+ export async function startWorkclawGateway(options: WorkclawGatewayOptions): Promise<void> {
40
+ const { accountId, account, cfg, log } = options
41
+
42
+ const logger = createLogger('', log)
43
+ setWorkclawLoggerFromContext(logger)
44
+
45
+ const rawConfig = account.config as unknown as WorkclawConfig & WorkclawAccountConfig
46
+ const connectionMode = rawConfig.connectionMode || 'websocket'
47
+
48
+ if (connectionMode !== 'websocket') {
49
+ throw new Error(`Unsupported connectionMode: ${connectionMode}`)
50
+ }
51
+
52
+ await startSharedWebSocket(options)
53
+ }
54
+
55
+ /**
56
+ * 多个账号共享一个 WebSocket 连接
57
+ */
58
+ async function startSharedWebSocket(options: WorkclawGatewayOptions): Promise<void> {
59
+ const { accountId, account, cfg, log } = options
60
+
61
+ const logger = createLogger('', log)
62
+ const rawConfig = account.config as unknown as WorkclawConfig & WorkclawAccountConfig
63
+
64
+ const baseConfig: WorkClawBaseConfig = {
65
+ baseUrl: rawConfig.baseUrl,
66
+ websocketUrl: rawConfig.websocketUrl,
67
+ appKey: rawConfig.appKey,
68
+ appSecret: rawConfig.appSecret,
69
+ localIp: rawConfig.localIp,
70
+ allowInsecureTls: rawConfig.allowInsecureTls,
71
+ requestTimeout: rawConfig.requestTimeout,
72
+ }
73
+
74
+ const accountConfig: WorkclawAccountConfig = {
75
+ agentId: rawConfig.agentId,
76
+ userId: rawConfig.userId,
77
+ openConversationId: rawConfig.openConversationId,
78
+ }
79
+
80
+ const appKey = baseConfig.appKey || accountId
81
+ const appKeyRaw = baseConfig.appKey ?? ''
82
+
83
+ buildAccountMap(cfg)
84
+
85
+ logger.info(
86
+ `Gateway start accountId=${accountId} appKey=${appKeyRaw ? `${appKeyRaw.slice(0, 8)}...` : 'missing'} baseUrl=${baseConfig.baseUrl ?? ''} websocketUrl=${baseConfig.websocketUrl ?? ''}`,
87
+ )
88
+
89
+ // 注册账号上下文
90
+ const ctx: MessageDispatcherContext = {
91
+ accountId,
92
+ account,
93
+ cfg,
94
+ baseConfig,
95
+ accountConfig,
96
+ log: logger,
97
+ scheduleReconnect: () => {
98
+ const scheduler = getReconnectScheduler(appKey)
99
+ scheduler?.scheduleReconnect()
100
+ },
101
+ }
102
+
103
+ registerAccountContext(appKey, accountId, ctx)
104
+
105
+ // 检查是否已存在该 appKey 的 WebSocket
106
+ const existingWs = getWorkclawWsConnection(appKey)
107
+ if (existingWs) {
108
+ logger.info(`Gateway using existing WebSocket for appKey=${appKey}`)
109
+ return
110
+ }
111
+
112
+ // 尝试获取连接锁,防止竞态条件
113
+ const startResult = tryStartConnecting(appKey)
114
+
115
+ if (!startResult.isConnector) {
116
+ // 另一个账号正在连接(成功或失败中),等待共享的 connectingPromise
117
+ logger.info(`Gateway another account is connecting, waiting... appKey=${appKey}`)
118
+ const resultWs = await startResult.connectingPromise
119
+ if (resultWs) {
120
+ logger.info(`Gateway connection established by another account, using it appKey=${appKey}`)
121
+ return
122
+ }
123
+ // 连接失败(resultWs === null),当前账号作为新 connector 重试
124
+ logger.warn(`Gateway connection attempt failed, will create own connection appKey=${appKey}`)
125
+ }
126
+ else {
127
+ // 是 connector,创建连接
128
+ logger.info(`Gateway acquired connector lock, creating connection appKey=${appKey}`)
129
+ }
130
+
131
+ // 需要创建新的 WebSocket(只有获取到锁的账号才执行到这里)
132
+ const scheduler = createReconnectScheduler({
133
+ key: appKey,
134
+ config: baseConfig,
135
+ log: logger,
136
+ onMessage: (data: string) => {
137
+ handleSharedMessage(appKey, data, cfg, logger)
138
+ },
139
+ onClose: () => {
140
+ scheduler.scheduleReconnect()
141
+ },
142
+ // 旧代码在 setWorkclawWsConnection 后、ws.onopen 前就调用 finishConnecting,
143
+ // 这个回调模拟同样的时机:ws 已存入 runtime,通知所有等待者
144
+ onConnectingStarted: (ws: any) => {
145
+ finishConnecting(appKey, ws)
146
+ },
147
+ })
148
+ setReconnectScheduler(appKey, scheduler)
149
+
150
+ try {
151
+ await scheduler.connect()
152
+ }
153
+ catch (err) {
154
+ // 连接失败,通知所有等待者重试(不传 ws 表示失败)
155
+ finishConnecting(appKey)
156
+ throw err
157
+ }
158
+
159
+ // Cache the connection config to prevent cfg mutation issues during message sends
160
+ // 每个账户都有自己的配置缓存
161
+ setWorkclawConnectionConfig(accountId, {
162
+ appKey: baseConfig.appKey ?? '',
163
+ appSecret: baseConfig.appSecret ?? '',
164
+ baseUrl: baseConfig.baseUrl,
165
+ websocketUrl: baseConfig.websocketUrl,
166
+ localIp: baseConfig.localIp,
167
+ allowInsecureTls: baseConfig.allowInsecureTls,
168
+ requestTimeout: baseConfig.requestTimeout,
169
+ })
170
+ }
171
+
172
+ /**
173
+ * 处理共享 WebSocket 消息
174
+ */
175
+ function handleSharedMessage(appKey: string, data: string, cfg: any, logger: any): void {
176
+ const parsed = parseWorkClawMessage(data, logger)
177
+
178
+ if (!parsed) {
179
+ logger.warn?.(`Gateway failed to parse message`)
180
+ return
181
+ }
182
+
183
+ // ping/pong/disconnect 无需路由
184
+ if (parsed.type === 'ping') {
185
+ const ws = getWorkclawWsConnection(appKey)
186
+ if (ws && ws.readyState === 1) { // OPEN
187
+ const pongResponse = {
188
+ code: 200,
189
+ message: 'pong',
190
+ metadata: { contentType: 'application/json' },
191
+ data: JSON.stringify(parsed.pongData),
192
+ }
193
+ ws.send(JSON.stringify(pongResponse))
194
+ }
195
+ return
196
+ }
197
+
198
+ if (parsed.type === 'disconnect') {
199
+ const ws = getWorkclawWsConnection(appKey)
200
+ ws?.close()
201
+ return
202
+ }
203
+
204
+ // logger.info?.(`Gateway received message: ${data}`);
205
+
206
+ // 根据消息类型确定 userId 和 agentId
207
+ let userId: string = ''
208
+ let agentId: string = ''
209
+
210
+ if (parsed.type === 'agent_message' && parsed.message) {
211
+ userId = String(parsed.message.userId || '')
212
+ agentId = String(parsed.message.agentId || '')
213
+ }
214
+ else if (parsed.type === 'agent_created') {
215
+ // agent_created 触发新账号创建
216
+ const allDispatchers = getAllDispatchersByAppKey(appKey)
217
+ if (allDispatchers && allDispatchers.size > 0) {
218
+ const firstDispatcher = allDispatchers.values().next().value
219
+ dispatchWorkclawMessage(data, firstDispatcher.ctx).catch((err) => {
220
+ logger.error?.(`Dispatch error: ${String(err)}`)
221
+ })
222
+ }
223
+ return
224
+ }
225
+ else if (parsed.type === 'agent_updated' || parsed.type === 'agent_deleted') {
226
+ userId = String(parsed.eventData?.futureId || parsed.eventData?.userId || '')
227
+ agentId = String(parsed.eventData?.id || parsed.eventData?.agentId || '')
228
+ }
229
+ else if (parsed.type === 'tools_list' || parsed.type === 'skills_list' || parsed.type === 'skills_event') {
230
+ userId = String(parsed.eventData?.userId || '')
231
+ agentId = String(parsed.eventData?.agentId || '')
232
+ }
233
+ else if (parsed.type === 'init_agent') {
234
+ userId = String(parsed.eventData?.futureId || parsed.eventData?.userId || '')
235
+ agentId = String(parsed.eventData?.id || parsed.eventData?.agentId || '')
236
+ /**
237
+ * 在 init_agent 中,配置文件的agentId 为空,所有无法通过 resolveAccountByUserIdAndAgentId 查找账户
238
+ */
239
+
240
+ const accountId = 'default'
241
+ const dispatcher = getDispatcherByAppKeyAndAccountId(appKey, accountId)
242
+ if (dispatcher) {
243
+ dispatchWorkclawMessage(data, dispatcher.ctx).catch((err) => {
244
+ logger.error?.(`Dispatch error: ${String(err)}`)
245
+ })
246
+ }
247
+ else {
248
+ logger.warn?.(`Gateway no dispatcher for accountId=${accountId}`)
249
+ }
250
+ return
251
+ }
252
+ else if (parsed.type === 'cron_task_event') {
253
+ // 任务事件处理,使用默认账户
254
+ const accountId = 'default'
255
+ const dispatcher = getDispatcherByAppKeyAndAccountId(appKey, accountId)
256
+ if (dispatcher) {
257
+ dispatchWorkclawMessage(data, dispatcher.ctx).catch((err) => {
258
+ logger.error?.(`Dispatch error: ${String(err)}`)
259
+ })
260
+ }
261
+ else {
262
+ logger.warn?.(`Gateway (per-appKey) no dispatcher for accountId=${accountId}`)
263
+ }
264
+ return
265
+ }
266
+ else {
267
+ // 未知消息类型,忽略
268
+ return
269
+ }
270
+
271
+ const accountId = resolveAccountByUserIdAndAgentId(cfg, userId, agentId)
272
+ if (!accountId) {
273
+ logger.warn?.(`Gateway no account found for userId=${userId} agentId=${agentId}`)
274
+ return
275
+ }
276
+
277
+ const dispatcher = getDispatcherByAppKeyAndAccountId(appKey, accountId)
278
+ if (dispatcher) {
279
+ dispatchWorkclawMessage(data, dispatcher.ctx).catch((err) => {
280
+ logger.error?.(`Dispatch error: ${String(err)}`)
281
+ })
282
+ }
283
+ else {
284
+ logger.warn?.(`Gateway no dispatcher for accountId=${accountId}`)
285
+ }
286
+ }
287
+
288
+ export function stopWorkclawGateway(accountId: string): void {
289
+ const appKey = getAppKeyByAccountId(accountId)
290
+ if (!appKey)
291
+ return
292
+
293
+ unregisterAccountContext(appKey, accountId)
294
+
295
+ if (getAllDispatchersByAppKey(appKey)?.size === 0) {
296
+ const ws = getWorkclawWsConnection(appKey)
297
+ ws?.close()
298
+ clearWorkclawWsConnection(appKey)
299
+
300
+ const scheduler = getReconnectScheduler(appKey)
301
+ scheduler?.stopReconnect()
302
+ clearReconnectScheduler(appKey)
303
+ }
304
+ clearWorkclawConnectionConfig(accountId)
305
+ }
@@ -0,0 +1,168 @@
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'
5
+ import { Agent, fetch as undiciFetch } from 'undici'
6
+
7
+ export function isLocalMediaSource(value: string): boolean {
8
+ const trimmed = value.trim()
9
+ return (
10
+ trimmed.startsWith('/')
11
+ || trimmed.startsWith('./')
12
+ || trimmed.startsWith('../')
13
+ || trimmed.startsWith('~')
14
+ || trimmed.startsWith('file://')
15
+ )
16
+ }
17
+
18
+ export function resolveLocalPath(input: string): string {
19
+ if (input.startsWith('file://')) {
20
+ return fileURLToPath(input)
21
+ }
22
+ if (input.startsWith('~')) {
23
+ return path.join(os.homedir(), input.slice(1))
24
+ }
25
+ return path.resolve(input)
26
+ }
27
+
28
+ function readResponseUrlPath(
29
+ data: Record<string, unknown>,
30
+ pathValue?: string,
31
+ ): string | undefined {
32
+ if (!pathValue)
33
+ return undefined
34
+ const parts = pathValue.split('.').filter(Boolean)
35
+ let current: unknown = data
36
+ for (const part of parts) {
37
+ if (!current || typeof current !== 'object')
38
+ return undefined
39
+ current = (current as Record<string, unknown>)[part]
40
+ }
41
+ return typeof current === 'string' ? current : undefined
42
+ }
43
+
44
+ function extractUploadedUrl(
45
+ responseText: string,
46
+ responseData: Record<string, unknown>,
47
+ pathValue?: string,
48
+ ): string | undefined {
49
+ const fromPath = readResponseUrlPath(responseData, pathValue)
50
+ if (fromPath)
51
+ return fromPath
52
+
53
+ const direct
54
+ = (responseData.url as string | undefined)
55
+ ?? (responseData.mediaUrl as string | undefined)
56
+ if (typeof direct === 'string' && direct.trim())
57
+ return direct
58
+
59
+ const dataObj = responseData.data
60
+ if (dataObj && typeof dataObj === 'object') {
61
+ const dataUrl = (dataObj as Record<string, unknown>).url
62
+ const dataMedia = (dataObj as Record<string, unknown>).mediaUrl
63
+ if (typeof dataUrl === 'string' && dataUrl.trim())
64
+ return dataUrl
65
+ if (typeof dataMedia === 'string' && dataMedia.trim())
66
+ return dataMedia
67
+ }
68
+
69
+ const resultObj = responseData.result
70
+ if (resultObj && typeof resultObj === 'object') {
71
+ const resultUrl = (resultObj as Record<string, unknown>).url
72
+ const resultMedia = (resultObj as Record<string, unknown>).mediaUrl
73
+ if (typeof resultUrl === 'string' && resultUrl.trim())
74
+ return resultUrl
75
+ if (typeof resultMedia === 'string' && resultMedia.trim())
76
+ return resultMedia
77
+ }
78
+
79
+ if (responseText.trim() && /^https?:\/\//i.test(responseText.trim())) {
80
+ return responseText.trim()
81
+ }
82
+ return undefined
83
+ }
84
+
85
+ export interface UploadLocalMediaParams {
86
+ uploadUrl: string
87
+ filePath: string
88
+ uploadFieldName?: string
89
+ uploadHeaders?: Record<string, string>
90
+ uploadFormFields?: Record<string, string | number | boolean>
91
+ uploadResponseUrlPath?: string
92
+ requestTimeout: number
93
+ allowInsecureTls?: boolean
94
+ }
95
+
96
+ export async function uploadLocalMedia(params: UploadLocalMediaParams): Promise<string> {
97
+ const stats = await stat(params.filePath)
98
+ if (!stats.isFile()) {
99
+ throw new Error(`mediaUrl is not a file: ${params.filePath}`)
100
+ }
101
+
102
+ const content = await readFile(params.filePath)
103
+ const form = new FormData()
104
+ const fieldName = params.uploadFieldName?.trim() || 'file'
105
+ const fileName = path.basename(params.filePath)
106
+ form.set(fieldName, new Blob([content], { type: 'application/octet-stream' }), fileName)
107
+
108
+ if (params.uploadFormFields) {
109
+ for (const [key, value] of Object.entries(params.uploadFormFields)) {
110
+ form.set(key, String(value))
111
+ }
112
+ }
113
+
114
+ const controller = new AbortController()
115
+ const timeoutId = setTimeout(() => controller.abort(), params.requestTimeout)
116
+
117
+ let dispatcher: unknown
118
+ let doFetch: any = globalThis.fetch.bind(globalThis)
119
+
120
+ if (params.allowInsecureTls) {
121
+ try {
122
+ dispatcher = new Agent({ connect: { rejectUnauthorized: false } })
123
+ doFetch = undiciFetch as any
124
+ }
125
+ catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error)
127
+ throw new Error(`allowInsecureTls requires undici. ${message}`)
128
+ }
129
+ }
130
+
131
+ try {
132
+ const response = await doFetch(params.uploadUrl, {
133
+ method: 'POST',
134
+ headers: params.uploadHeaders ?? {},
135
+ body: form,
136
+ signal: controller.signal,
137
+ ...(dispatcher ? { dispatcher } : {}),
138
+ })
139
+
140
+ const responseText = await response.text().catch(() => '')
141
+ if (!response.ok) {
142
+ throw new Error(`Upload failed: ${response.status} ${responseText}`)
143
+ }
144
+
145
+ let data: Record<string, unknown> = {}
146
+ if (responseText) {
147
+ try {
148
+ data = JSON.parse(responseText) as Record<string, unknown>
149
+ }
150
+ catch {
151
+ data = {}
152
+ }
153
+ }
154
+
155
+ const uploadedUrl = extractUploadedUrl(
156
+ responseText,
157
+ data,
158
+ params.uploadResponseUrlPath,
159
+ )
160
+ if (!uploadedUrl) {
161
+ throw new Error('Upload response missing file URL')
162
+ }
163
+ return uploadedUrl
164
+ }
165
+ finally {
166
+ clearTimeout(timeoutId)
167
+ }
168
+ }
@@ -0,0 +1,191 @@
1
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk'
2
+ import type { WorkclawConfig, WorkclawSendResult } from '../types.js'
3
+ import { stat } from 'node:fs/promises'
4
+ import { resolveAccountByUserIdAndAgentId, resolveWorkclawAccountWithCache } from '../accounts.js'
5
+
6
+ import { loadOpenConversationId } from '../gateway/config-writer.js'
7
+ import { isLocalMediaSource, resolveLocalPath, uploadLocalMedia } from '../media/upload.js'
8
+ import { getWorkclawLogger, setLastOutboundAt } from '../runtime.js'
9
+ import {
10
+ sendWorkclawOutboundMessage,
11
+ } from './workclaw-sender.js'
12
+
13
+ /**
14
+ * 根据 target (userId) 和可选的 agentId 查找匹配的账户
15
+ * 如果同时提供 userId 和 agentId,走 O(1) 查找
16
+ * 否则遍历所有账户,查找 userId 等于 target 的账户
17
+ */
18
+ function findAccountIdByTarget(
19
+ cfg: OpenClawConfig,
20
+ target: string,
21
+ agentId?: string | number,
22
+ ): string | undefined {
23
+ // O(1) 查找:userId + agentId 唯一确定一个账户
24
+ if (agentId !== undefined && agentId !== null) {
25
+ return resolveAccountByUserIdAndAgentId(cfg, target, String(agentId)) ?? undefined
26
+ }
27
+
28
+ // 遍历:只有 userId 时,无法唯一确定账户(一个 userId 对应多个 agentId)
29
+ // 需要通过 bindings 或其他方式 resolve,这里暂不支持
30
+ return undefined
31
+ }
32
+
33
+ export interface SendWorkclawMessageParams {
34
+ cfg: OpenClawConfig
35
+ to: string
36
+ text: string
37
+ mediaUrl?: string
38
+ replyToMessageId?: string
39
+ accountId?: string
40
+ openConversationId?: string
41
+ agentId?: string | number
42
+ /**
43
+ * 消息类型:
44
+ * - "reply": 回复消息(用户触发)
45
+ * - "push": 主动推送(定时任务触发)
46
+ */
47
+ messageType?: 'reply' | 'push'
48
+ msgType?: string
49
+ last?: boolean
50
+ /** 来源:deliver=流式响应回调,sendText=框架直接发送,after_tool_call=工具执行结果推送 */
51
+ source?: 'deliver' | 'sendText' | 'after_tool_call'
52
+ }
53
+
54
+ async function resolveMediaUrl(
55
+ mediaUrl: string | undefined,
56
+ config: WorkclawConfig,
57
+ ): Promise<string | undefined> {
58
+ if (!mediaUrl)
59
+ return undefined
60
+ if (!isLocalMediaSource(mediaUrl))
61
+ return mediaUrl
62
+
63
+ const uploadUrl = config.uploadUrl
64
+ if (!uploadUrl) {
65
+ throw new Error('uploadUrl not configured for local mediaUrl')
66
+ }
67
+
68
+ const filePath = resolveLocalPath(mediaUrl)
69
+
70
+ if (typeof config.mediaMaxMb === 'number' && config.mediaMaxMb > 0) {
71
+ const stats = await stat(filePath)
72
+ const maxBytes = config.mediaMaxMb * 1024 * 1024
73
+ if (stats.size > maxBytes) {
74
+ throw new Error(`mediaUrl exceeds limit (${config.mediaMaxMb} MB)`)
75
+ }
76
+ }
77
+
78
+ return uploadLocalMedia({
79
+ uploadUrl,
80
+ filePath,
81
+ uploadFieldName: config.uploadFieldName,
82
+ uploadHeaders: config.uploadHeaders as Record<string, string> | undefined,
83
+ uploadFormFields: config.uploadFormFields as
84
+ | Record<string, string | number | boolean>
85
+ | undefined,
86
+ uploadResponseUrlPath: config.uploadResponseUrlPath,
87
+ requestTimeout: config.requestTimeout ?? 30000,
88
+ allowInsecureTls: config.allowInsecureTls,
89
+ })
90
+ }
91
+
92
+ export async function sendMessageWorkclaw(
93
+ params: SendWorkclawMessageParams,
94
+ ): Promise<WorkclawSendResult> {
95
+ const { cfg, to, text, mediaUrl, replyToMessageId, accountId, openConversationId, agentId, messageType, msgType, last }
96
+ = params
97
+
98
+ // 如果没有指定 messageType,根据是否有 replyToMessageId 来判断
99
+ // - 有 replyToMessageId: 回复消息
100
+ // - 没有 replyToMessageId: 主动推送
101
+ const resolvedMessageType = messageType ?? (replyToMessageId ? 'reply' : 'push')
102
+
103
+ // 如果没有指定 accountId,尝试根据 target (to) 和 agentId 查找匹配的账户
104
+ let resolvedAccountId = accountId
105
+ if (!resolvedAccountId) {
106
+ const foundAccountId = findAccountIdByTarget(cfg, to, agentId)
107
+ if (foundAccountId) {
108
+ resolvedAccountId = foundAccountId
109
+ }
110
+ }
111
+
112
+ const account = resolveWorkclawAccountWithCache({ cfg, accountId: resolvedAccountId })
113
+ if (!account.configured) {
114
+ throw new Error(`Workclaw account "${account.accountId}" not configured`)
115
+ }
116
+
117
+ const resolvedMediaUrl = await resolveMediaUrl(mediaUrl, account.config)
118
+
119
+ // 优先使用传入的 openConversationId,其次从 cfg 中直接读取(绕过 account.config 快照问题)
120
+ // 这样可以获取到运行时动态保存的 openConversationId
121
+ interface ChannelWithAccounts { accounts?: Record<string, any>, openConversationId?: string }
122
+ const cfgChannel = (cfg.channels?.['openclaw-workclaw'] as unknown as ChannelWithAccounts) ?? {}
123
+ const cfgAccounts = cfgChannel.accounts ?? {}
124
+ const cfgAccount = cfgAccounts[resolvedAccountId] ?? {}
125
+ const cfgOpenConversationId = cfgAccount?.openConversationId ?? cfgChannel?.openConversationId
126
+ // 最后尝试从 state 文件恢复(进程重启后 cfg 没有持久化的 openConversationId)
127
+ const stateOpenConversationId = loadOpenConversationId(resolvedAccountId, to)
128
+ const effectiveOpenConversationId = openConversationId ?? cfgOpenConversationId ?? stateOpenConversationId
129
+ getWorkclawLogger().info(`[SEND] source=${params.source || '?'} type=${resolvedMessageType} conv=${effectiveOpenConversationId || '(none)'} replyTo=${replyToMessageId}`)
130
+
131
+ // 使用 appKey 作为缓存键,让所有账户共享同一个 token
132
+ const tokenCacheKey = account.config.appKey || account.accountId
133
+ const result = await sendWorkclawOutboundMessage({
134
+ cacheKey: tokenCacheKey,
135
+ to,
136
+ text,
137
+ msgType,
138
+ mediaUrl: resolvedMediaUrl,
139
+ replyToMessageId,
140
+ openConversationId: effectiveOpenConversationId,
141
+ agentId,
142
+ config: account.config,
143
+ messageType: resolvedMessageType,
144
+ last,
145
+ source: params.source,
146
+ })
147
+
148
+ // 更新上次出站时间
149
+ if (resolvedAccountId) {
150
+ setLastOutboundAt(resolvedAccountId, Date.now())
151
+ }
152
+
153
+ return {
154
+ messageId: result.messageId,
155
+ chatId: to,
156
+ }
157
+ }
158
+
159
+ export async function getMessageWorkclaw(_params: {
160
+ cfg: OpenClawConfig
161
+ messageId: string
162
+ accountId?: string
163
+ }): Promise<null> {
164
+ return null
165
+ }
166
+
167
+ /**
168
+ * 发送主动推送消息(定时任务使用)
169
+ * 使用 pushEndpoint 配置的端口
170
+ */
171
+ export async function sendPushMessageWorkclaw(
172
+ params: Omit<SendWorkclawMessageParams, 'messageType'>,
173
+ ): Promise<WorkclawSendResult> {
174
+ return sendMessageWorkclaw({
175
+ ...params,
176
+ messageType: 'push',
177
+ })
178
+ }
179
+
180
+ /**
181
+ * 发送回复消息(用户触发)
182
+ * 使用 replyEndpoint 配置的端口
183
+ */
184
+ export async function sendReplyMessageWorkclaw(
185
+ params: Omit<SendWorkclawMessageParams, 'messageType'>,
186
+ ): Promise<WorkclawSendResult> {
187
+ return sendMessageWorkclaw({
188
+ ...params,
189
+ messageType: 'reply',
190
+ })
191
+ }