@workclaw/openclaw-workclaw 1.0.0

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 (48) hide show
  1. package/README.md +325 -0
  2. package/index.ts +298 -0
  3. package/openclaw.plugin.json +10 -0
  4. package/package.json +43 -0
  5. package/skills/openclaw-workclaw-cron/SKILL.md +458 -0
  6. package/src/accounts.ts +287 -0
  7. package/src/api/accounts-api.ts +157 -0
  8. package/src/api/prompts-api.ts +123 -0
  9. package/src/api/session-api.ts +247 -0
  10. package/src/api/skills-api.ts +74 -0
  11. package/src/api/workspace.ts +43 -0
  12. package/src/channel.ts +227 -0
  13. package/src/config-schema.ts +110 -0
  14. package/src/connection/workclaw-client.ts +656 -0
  15. package/src/gateway/agent-handlers.ts +557 -0
  16. package/src/gateway/config-writer.ts +311 -0
  17. package/src/gateway/message-context.ts +422 -0
  18. package/src/gateway/message-dispatcher.ts +601 -0
  19. package/src/gateway/reconnect.ts +149 -0
  20. package/src/gateway/skills-handler.ts +759 -0
  21. package/src/gateway/skills-list-handler.ts +332 -0
  22. package/src/gateway/tools-list-handler.ts +162 -0
  23. package/src/gateway/workclaw-gateway.ts +521 -0
  24. package/src/media/upload.ts +168 -0
  25. package/src/outbound/index.ts +183 -0
  26. package/src/outbound/workclaw-sender.ts +157 -0
  27. package/src/runtime.ts +400 -0
  28. package/src/send.ts +1 -0
  29. package/src/tools/openclaw-workclaw-cron/api/index.ts +326 -0
  30. package/src/tools/openclaw-workclaw-cron/index.ts +39 -0
  31. package/src/tools/openclaw-workclaw-cron/src/add/params.ts +176 -0
  32. package/src/tools/openclaw-workclaw-cron/src/add/sync.ts +188 -0
  33. package/src/tools/openclaw-workclaw-cron/src/disable/params.ts +100 -0
  34. package/src/tools/openclaw-workclaw-cron/src/disable/sync.ts +127 -0
  35. package/src/tools/openclaw-workclaw-cron/src/enable/params.ts +100 -0
  36. package/src/tools/openclaw-workclaw-cron/src/enable/sync.ts +127 -0
  37. package/src/tools/openclaw-workclaw-cron/src/notify/sync.ts +148 -0
  38. package/src/tools/openclaw-workclaw-cron/src/remove/params.ts +109 -0
  39. package/src/tools/openclaw-workclaw-cron/src/remove/sync.ts +127 -0
  40. package/src/tools/openclaw-workclaw-cron/src/update/params.ts +197 -0
  41. package/src/tools/openclaw-workclaw-cron/src/update/sync.ts +161 -0
  42. package/src/tools/openclaw-workclaw-cron/types/index.ts +55 -0
  43. package/src/tools/openclaw-workclaw-cron/utils/index.ts +141 -0
  44. package/src/types.ts +60 -0
  45. package/src/utils/content.ts +40 -0
  46. package/templates/IDENTITY.md +14 -0
  47. package/templates/SOUL.md +0 -0
  48. package/tsconfig.json +11 -0
@@ -0,0 +1,521 @@
1
+ import type { OpenclawWorkclawAccountConfig, OpenclawWorkclawConfig, ResolvedOpenclawWorkclawAccount, WorkClawBaseConfig } from '../types.js'
2
+ import type { MessageDispatcherContext } from './message-dispatcher.js'
3
+ import { buildAccountMap, resolveAccountByUserIdAndAgentId } from '../accounts.js'
4
+ import { clearOpenclawWorkclawTokenCache, openOpenclawWorkclawConnection } from '../connection/workclaw-client.js'
5
+ import {
6
+ clearOpenclawWorkclawWsConnection,
7
+ clearReconnectScheduler,
8
+ createLogger,
9
+ finishConnecting,
10
+ getAllDispatchersByAppKey,
11
+ getAppKeyByAccountId,
12
+ getDispatcherByAppKeyAndAccountId,
13
+ getOpenclawWorkclawWsConnection,
14
+ getReconnectScheduler,
15
+ registerAccountContext,
16
+ setOpenclawWorkclawLoggerFromContext,
17
+ setOpenclawWorkclawWsConnection,
18
+ setReconnectScheduler,
19
+ tryStartConnecting,
20
+ unregisterAccountContext,
21
+ } from '../runtime.js'
22
+ import { parseWorkClawMessage } from './message-context.js'
23
+ import { dispatchOpenclawWorkclawMessage } from './message-dispatcher.js'
24
+ import { createReconnectScheduler } from './reconnect.js'
25
+
26
+ export interface OpenclawWorkclawGatewayOptions {
27
+ accountId: string
28
+ account: ResolvedOpenclawWorkclawAccount
29
+ cfg: any
30
+ log?: any
31
+ }
32
+
33
+ export async function startOpenclawWorkclawGateway(options: OpenclawWorkclawGatewayOptions): Promise<void> {
34
+ const { account, log } = options
35
+
36
+ const logger = createLogger('', log)
37
+ setOpenclawWorkclawLoggerFromContext(logger)
38
+
39
+ const rawConfig = account.config as unknown as OpenclawWorkclawConfig & OpenclawWorkclawAccountConfig
40
+ const connectionMode = rawConfig.connectionMode || 'websocket'
41
+ const wsStrategy = rawConfig.wsConnectionStrategy || 'per-account'
42
+
43
+ if (connectionMode !== 'websocket') {
44
+ throw new Error(`Unsupported connectionMode: ${connectionMode}`)
45
+ }
46
+
47
+ if (wsStrategy === 'per-appKey') {
48
+ await startSharedWebSocket(options)
49
+ }
50
+ else {
51
+ await startPerAccountWebSocket(options)
52
+ }
53
+ }
54
+
55
+ /**
56
+ * per-account 模式: 每个账号独立 WebSocket 连接
57
+ */
58
+ async function startPerAccountWebSocket(options: OpenclawWorkclawGatewayOptions): Promise<void> {
59
+ const { accountId, account, cfg, log } = options
60
+
61
+ const logger = createLogger('', log)
62
+ const rawConfig = account.config as unknown as OpenclawWorkclawConfig & OpenclawWorkclawAccountConfig
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: OpenclawWorkclawAccountConfig = {
75
+ agentId: rawConfig.agentId,
76
+ userId: rawConfig.userId,
77
+ openConversationId: rawConfig.openConversationId,
78
+ }
79
+
80
+ const appKeyRaw = baseConfig.appKey ?? ''
81
+ const appSecretRaw = baseConfig.appSecret ?? ''
82
+
83
+ buildAccountMap(cfg)
84
+
85
+ logger.info(
86
+ `Gateway start (per-account) accountId=${accountId} baseUrl=${baseConfig.baseUrl ?? ''} websocketUrl=${baseConfig.websocketUrl ?? ''} localIp=${baseConfig.localIp ?? ''} allowInsecureTls=${baseConfig.allowInsecureTls} requestTimeout=${baseConfig.requestTimeout ?? ''} appKeyPrefix=${appKeyRaw ? `${appKeyRaw.slice(0, 8)}...` : 'missing'} appSecretLen=${appSecretRaw.length}`,
87
+ )
88
+
89
+ const ctx: MessageDispatcherContext = {
90
+ accountId,
91
+ account,
92
+ cfg,
93
+ baseConfig,
94
+ accountConfig,
95
+ log: logger,
96
+ scheduleReconnect: () => {
97
+ // eslint-disable-next-line ts/no-use-before-define
98
+ scheduler.scheduleReconnect()
99
+ },
100
+ }
101
+
102
+ const handleMessage = (data: string): void | Promise<void> => {
103
+ dispatchOpenclawWorkclawMessage(data, ctx).catch((err) => {
104
+ logger.error(`Dispatch error: ${String(err)}`)
105
+ })
106
+ }
107
+
108
+ const scheduler = createReconnectScheduler({
109
+ key: accountId,
110
+ config: baseConfig,
111
+ log: logger,
112
+ onMessage: handleMessage,
113
+ })
114
+
115
+ try {
116
+ const tokenCacheKey = baseConfig.appKey || accountId
117
+ const connConfig = {
118
+ baseUrl: baseConfig.baseUrl || '',
119
+ websocketUrl: baseConfig.websocketUrl,
120
+ appKey: baseConfig.appKey,
121
+ appSecret: baseConfig.appSecret,
122
+ localIp: baseConfig.localIp,
123
+ allowInsecureTls: baseConfig.allowInsecureTls,
124
+ requestTimeout: baseConfig.requestTimeout,
125
+ }
126
+
127
+ const { endpoint, ticket } = await openOpenclawWorkclawConnection(tokenCacheKey, connConfig)
128
+ logger.info(`Gateway Connection opened endpoint=${endpoint} ticketLen=${ticket?.length ?? 0}`)
129
+ const url = `${endpoint}?ticket=${encodeURIComponent(ticket)}`
130
+
131
+ const { Agent } = await import('undici')
132
+ const dispatcher = connConfig.allowInsecureTls
133
+ ? new Agent({ connect: { rejectUnauthorized: false } })
134
+ : undefined
135
+ const wsOptions = dispatcher ? { dispatcher } : undefined
136
+
137
+ const ws = new (await import('undici')).WebSocket(url, wsOptions)
138
+ setOpenclawWorkclawWsConnection(accountId, ws)
139
+
140
+ await new Promise<void>((resolve, reject) => {
141
+ const timeout = setTimeout(() => reject(new Error('websocket connection timeout')), 30000)
142
+
143
+ ws.onopen = () => {
144
+ clearTimeout(timeout)
145
+ logger.info(`Gateway WebSocket connected`)
146
+ resolve()
147
+ }
148
+
149
+ ws.onerror = (event: any) => {
150
+ clearTimeout(timeout)
151
+ const errorMsg = String(event?.message ?? event)
152
+ logger.error(`Gateway WebSocket error: ${errorMsg}`)
153
+ reject(new Error(errorMsg))
154
+ }
155
+
156
+ ws.onclose = (event: any) => {
157
+ clearTimeout(timeout)
158
+ reject(new Error(`websocket closed before ready code=${event?.code ?? ''} reason=${event?.reason ?? ''}`))
159
+ }
160
+ })
161
+
162
+ ws.onmessage = (event: any) => {
163
+ const data = typeof event.data === 'string' ? event.data : String(event.data ?? '')
164
+ logger.info(`Gateway WebSocket message received bytes=${data.length}`)
165
+ handleMessage(data)
166
+ }
167
+
168
+ ws.onclose = (event: any) => {
169
+ logger.warn(`Gateway WebSocket closed scheduling reconnect... accountId=${accountId} code=${event?.code ?? ''} reason=${event?.reason ?? ''}`)
170
+ clearOpenclawWorkclawWsConnection(accountId)
171
+ scheduler.scheduleReconnect()
172
+ }
173
+
174
+ ws.onerror = (event: any) => {
175
+ logger.error(`Gateway WebSocket error: ${String(event?.message ?? event)}`)
176
+ }
177
+ }
178
+ catch (err) {
179
+ const errorStr = String(err)
180
+ logger.error(`Gateway connect failed: ${errorStr}`)
181
+
182
+ if (errorStr.includes('访问过于频繁')) {
183
+ logger.warn(`Rate limited, scheduling reconnect...`)
184
+ scheduler.scheduleReconnect()
185
+ throw err
186
+ }
187
+
188
+ const isAuthError
189
+ = errorStr.includes('"errCode":2001')
190
+ || errorStr.includes('"code":2001')
191
+ || errorStr.includes('鉴权失败')
192
+ || errorStr.includes('invalid credentials')
193
+ || errorStr.includes('unauthorized')
194
+
195
+ if (isAuthError) {
196
+ logger.error(`Authentication failed, not scheduling reconnect`)
197
+ scheduler.stopReconnect()
198
+ throw err
199
+ }
200
+
201
+ scheduler.scheduleReconnect()
202
+ throw err
203
+ }
204
+ }
205
+
206
+ /**
207
+ * per-appKey 模式: 多个账号共享一个 WebSocket 连接
208
+ */
209
+ async function startSharedWebSocket(options: OpenclawWorkclawGatewayOptions): Promise<void> {
210
+ const { accountId, account, cfg, log } = options
211
+
212
+ const logger = createLogger('', log)
213
+ const rawConfig = account.config as unknown as OpenclawWorkclawConfig & OpenclawWorkclawAccountConfig
214
+
215
+ const baseConfig: WorkClawBaseConfig = {
216
+ baseUrl: rawConfig.baseUrl,
217
+ websocketUrl: rawConfig.websocketUrl,
218
+ appKey: rawConfig.appKey,
219
+ appSecret: rawConfig.appSecret,
220
+ localIp: rawConfig.localIp,
221
+ allowInsecureTls: rawConfig.allowInsecureTls,
222
+ requestTimeout: rawConfig.requestTimeout,
223
+ }
224
+
225
+ const accountConfig: OpenclawWorkclawAccountConfig = {
226
+ agentId: rawConfig.agentId,
227
+ userId: rawConfig.userId,
228
+ openConversationId: rawConfig.openConversationId,
229
+ }
230
+
231
+ const appKey = baseConfig.appKey || accountId
232
+ const appKeyRaw = baseConfig.appKey ?? ''
233
+
234
+ buildAccountMap(cfg)
235
+
236
+ logger.info(
237
+ `Gateway start (per-appKey) accountId=${accountId} appKey=${appKeyRaw ? `${appKeyRaw.slice(0, 8)}...` : 'missing'} baseUrl=${baseConfig.baseUrl ?? ''} websocketUrl=${baseConfig.websocketUrl ?? ''}`,
238
+ )
239
+
240
+ // 注册账号上下文
241
+ const ctx: MessageDispatcherContext = {
242
+ accountId,
243
+ account,
244
+ cfg,
245
+ baseConfig,
246
+ accountConfig,
247
+ log: logger,
248
+ scheduleReconnect: () => {
249
+ const scheduler = getReconnectScheduler(appKey)
250
+ scheduler?.scheduleReconnect()
251
+ },
252
+ }
253
+
254
+ registerAccountContext(appKey, accountId, ctx)
255
+
256
+ // 检查是否已存在该 appKey 的 WebSocket
257
+ const existingWs = getOpenclawWorkclawWsConnection(appKey)
258
+ if (existingWs) {
259
+ logger.info(`Gateway (per-appKey) using existing WebSocket for appKey=${appKey}`)
260
+ return
261
+ }
262
+
263
+ // 尝试获取连接锁,防止竞态条件
264
+ const isConnector = tryStartConnecting(appKey)
265
+ if (!isConnector) {
266
+ // 另一个账号正在连接,等待完成
267
+ logger.info(`Gateway (per-appKey) another account is connecting, waiting... appKey=${appKey}`)
268
+ // 等待直到连接建立完成(通过轮询)
269
+ const maxWait = 30000
270
+ const interval = 500
271
+ const startTime = Date.now()
272
+ while (Date.now() - startTime < maxWait) {
273
+ const ws = getOpenclawWorkclawWsConnection(appKey)
274
+ if (ws) {
275
+ logger.info(`Gateway (per-appKey) connection established by another account, using it appKey=${appKey}`)
276
+ return
277
+ }
278
+ await new Promise(resolve => setTimeout(resolve, interval))
279
+ }
280
+ logger.warn(`Gateway (per-appKey) wait timeout, will create own connection appKey=${appKey}`)
281
+ // 如果等待超时,由当前账号创建连接
282
+ }
283
+
284
+ // 需要创建新的 WebSocket(只有获取到锁的账号才执行到这里)
285
+ const scheduler = createReconnectScheduler({
286
+ key: appKey,
287
+ config: baseConfig,
288
+ log: logger,
289
+ onMessage: (data: string) => {
290
+ handleSharedMessage(appKey, data, cfg, logger)
291
+ },
292
+ onReconnectSuccess: () => {
293
+ // 重连成功后重置退避时间
294
+ },
295
+ })
296
+ setReconnectScheduler(appKey, scheduler)
297
+
298
+ try {
299
+ const tokenCacheKey = baseConfig.appKey || accountId
300
+ const connConfig = {
301
+ baseUrl: baseConfig.baseUrl || '',
302
+ websocketUrl: baseConfig.websocketUrl,
303
+ appKey: baseConfig.appKey,
304
+ appSecret: baseConfig.appSecret,
305
+ localIp: baseConfig.localIp,
306
+ allowInsecureTls: baseConfig.allowInsecureTls,
307
+ requestTimeout: baseConfig.requestTimeout,
308
+ }
309
+
310
+ const { endpoint, ticket } = await openOpenclawWorkclawConnection(tokenCacheKey, connConfig)
311
+ logger.info(`Gateway (per-appKey) Connection opened endpoint=${endpoint} ticketLen=${ticket?.length ?? 0}`)
312
+ const url = `${endpoint}?ticket=${encodeURIComponent(ticket)}`
313
+
314
+ const { Agent } = await import('undici')
315
+ const dispatcher = connConfig.allowInsecureTls
316
+ ? new Agent({ connect: { rejectUnauthorized: false } })
317
+ : undefined
318
+ const wsOptions = dispatcher ? { dispatcher } : undefined
319
+
320
+ const ws = new (await import('undici')).WebSocket(url, wsOptions)
321
+ setOpenclawWorkclawWsConnection(appKey, ws)
322
+ finishConnecting(appKey) // 连接已建立,释放锁
323
+
324
+ await new Promise<void>((resolve, reject) => {
325
+ const timeout = setTimeout(() => reject(new Error('websocket connection timeout')), 30000)
326
+
327
+ ws.onopen = () => {
328
+ clearTimeout(timeout)
329
+ logger.info(`Gateway (per-appKey) WebSocket connected`)
330
+ resolve()
331
+ }
332
+
333
+ ws.onerror = (event: any) => {
334
+ clearTimeout(timeout)
335
+ const errorMsg = String(event?.message ?? event)
336
+ logger.error(`Gateway (per-appKey) WebSocket error: ${errorMsg}`)
337
+ reject(new Error(errorMsg))
338
+ }
339
+
340
+ ws.onclose = (event: any) => {
341
+ clearTimeout(timeout)
342
+ reject(new Error(`websocket closed before ready code=${event?.code ?? ''} reason=${event?.reason ?? ''}`))
343
+ }
344
+ })
345
+
346
+ ws.onmessage = (event: any) => {
347
+ const data = typeof event.data === 'string' ? event.data : String(event.data ?? '')
348
+ logger.info(`Gateway (per-appKey) WebSocket message received bytes=${data.length}`)
349
+ logger.info?.(`Gateway (per-appKey) received message: ${data}`)
350
+ handleSharedMessage(appKey, data, cfg, logger)
351
+ }
352
+
353
+ ws.onclose = (event: any) => {
354
+ logger.warn(`Gateway (per-appKey) WebSocket closed scheduling reconnect... appKey=${appKey} code=${event?.code ?? ''} reason=${event?.reason ?? ''}`)
355
+ clearOpenclawWorkclawWsConnection(appKey)
356
+ scheduler.scheduleReconnect()
357
+ }
358
+
359
+ ws.onerror = (event: any) => {
360
+ logger.error(`Gateway (per-appKey) WebSocket error: ${String(event?.message ?? event)}`)
361
+ }
362
+ }
363
+ catch (err) {
364
+ finishConnecting(appKey) // 连接失败,释放锁
365
+ const errorStr = String(err)
366
+ logger.error(`Gateway (per-appKey) connect failed: ${errorStr}`)
367
+
368
+ if (errorStr.includes('访问过于频繁')) {
369
+ logger.warn(`Rate limited, scheduling reconnect...`)
370
+ scheduler.scheduleReconnect()
371
+ throw err
372
+ }
373
+
374
+ const isAuthError
375
+ = errorStr.includes('"errCode":2001')
376
+ || errorStr.includes('"code":2001')
377
+ || errorStr.includes('鉴权失败')
378
+ || errorStr.includes('invalid credentials')
379
+ || errorStr.includes('unauthorized')
380
+
381
+ if (isAuthError) {
382
+ logger.error(`Authentication failed, not scheduling reconnect`)
383
+ scheduler.stopReconnect()
384
+ throw err
385
+ }
386
+
387
+ scheduler.scheduleReconnect()
388
+ throw err
389
+ }
390
+ }
391
+
392
+ /**
393
+ * per-appKey 模式下处理共享 WebSocket 消息
394
+ */
395
+ function handleSharedMessage(appKey: string, data: string, cfg: any, logger: any): void {
396
+ const parsed = parseWorkClawMessage(data, logger)
397
+
398
+ if (!parsed) {
399
+ logger.warn?.(`Gateway (per-appKey) failed to parse message`)
400
+ return
401
+ }
402
+
403
+ // ping/pong/disconnect 无需路由
404
+ if (parsed.type === 'ping') {
405
+ const ws = getOpenclawWorkclawWsConnection(appKey)
406
+ if (ws && ws.readyState === 1) { // OPEN
407
+ const pongResponse = {
408
+ code: 200,
409
+ message: 'pong',
410
+ metadata: { contentType: 'application/json' },
411
+ data: JSON.stringify(parsed.pongData),
412
+ }
413
+ ws.send(JSON.stringify(pongResponse))
414
+ }
415
+ return
416
+ }
417
+
418
+ if (parsed.type === 'disconnect') {
419
+ const ws = getOpenclawWorkclawWsConnection(appKey)
420
+ ws?.close()
421
+ return
422
+ }
423
+
424
+ logger.info?.(`Gateway (per-appKey) received message: ${data}`)
425
+
426
+ // 根据消息类型确定 userId 和 agentId
427
+ let userId: string = ''
428
+ let agentId: string = ''
429
+
430
+ if (parsed.type === 'agent_message' && parsed.message) {
431
+ userId = String(parsed.message.userId || '')
432
+ agentId = String(parsed.message.agentId || '')
433
+ }
434
+ else if (parsed.type === 'agent_created') {
435
+ // agent_created 触发新账号创建
436
+ const allDispatchers = getAllDispatchersByAppKey(appKey)
437
+ if (allDispatchers && allDispatchers.size > 0) {
438
+ const firstDispatcher = allDispatchers.values().next().value
439
+ if (firstDispatcher) {
440
+ dispatchOpenclawWorkclawMessage(data, firstDispatcher.ctx).catch((err) => {
441
+ logger.error?.(`Dispatch error: ${String(err)}`)
442
+ })
443
+ }
444
+ }
445
+ return
446
+ }
447
+ else if (parsed.type === 'agent_updated' || parsed.type === 'agent_deleted') {
448
+ userId = String(parsed.eventData?.futureId || parsed.eventData?.userId || '')
449
+ agentId = String(parsed.eventData?.id || parsed.eventData?.agentId || '')
450
+ }
451
+ else if (parsed.type === 'tools_list' || parsed.type === 'skills_list' || parsed.type === 'skills_event') {
452
+ userId = String(parsed.eventData?.userId || '')
453
+ agentId = String(parsed.eventData?.agentId || '')
454
+ }
455
+ else if (parsed.type === 'init_agent') {
456
+ userId = String(parsed.eventData?.futureId || parsed.eventData?.userId || '')
457
+ agentId = String(parsed.eventData?.id || parsed.eventData?.agentId || '')
458
+ /**
459
+ * 在 init_agent 中,配置文件的agentId 为空,所有无法通过 resolveAccountByUserIdAndAgentId 查找账户
460
+ */
461
+
462
+ const accountId = 'default'
463
+ const dispatcher = getDispatcherByAppKeyAndAccountId(appKey, accountId)
464
+ if (dispatcher) {
465
+ dispatchOpenclawWorkclawMessage(data, dispatcher.ctx).catch((err) => {
466
+ logger.error?.(`Dispatch error: ${String(err)}`)
467
+ })
468
+ }
469
+ else {
470
+ logger.warn?.(`Gateway (per-appKey) no dispatcher for accountId=${accountId}`)
471
+ }
472
+ return
473
+ }
474
+ else {
475
+ // 未知消息类型,忽略
476
+ return
477
+ }
478
+
479
+ const accountId = resolveAccountByUserIdAndAgentId(cfg, userId, agentId)
480
+ if (!accountId) {
481
+ logger.warn?.(`Gateway (per-appKey) no account found for userId=${userId} agentId=${agentId}`)
482
+ return
483
+ }
484
+
485
+ const dispatcher = getDispatcherByAppKeyAndAccountId(appKey, accountId)
486
+ if (dispatcher) {
487
+ dispatchOpenclawWorkclawMessage(data, dispatcher.ctx).catch((err) => {
488
+ logger.error?.(`Dispatch error: ${String(err)}`)
489
+ })
490
+ }
491
+ else {
492
+ logger.warn?.(`Gateway (per-appKey) no dispatcher for accountId=${accountId}`)
493
+ }
494
+ }
495
+
496
+ export function stopOpenclawWorkclawGateway(accountId: string, wsStrategy?: string): void {
497
+ if (wsStrategy === 'per-appKey') {
498
+ const appKey = getAppKeyByAccountId(accountId)
499
+ if (!appKey)
500
+ return
501
+
502
+ unregisterAccountContext(appKey, accountId)
503
+
504
+ if (getAllDispatchersByAppKey(appKey)?.size === 0) {
505
+ const ws = getOpenclawWorkclawWsConnection(appKey)
506
+ ws?.close()
507
+ clearOpenclawWorkclawWsConnection(appKey)
508
+
509
+ const scheduler = getReconnectScheduler(appKey)
510
+ scheduler?.stopReconnect()
511
+ clearReconnectScheduler(appKey)
512
+ }
513
+ }
514
+ else {
515
+ const ws = getOpenclawWorkclawWsConnection(accountId)
516
+ if (ws)
517
+ ws.close()
518
+ clearOpenclawWorkclawWsConnection(accountId)
519
+ clearOpenclawWorkclawTokenCache(accountId)
520
+ }
521
+ }
@@ -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
+
6
+ export function isLocalMediaSource(value: string): boolean {
7
+ const trimmed = value.trim()
8
+ return (
9
+ trimmed.startsWith('/')
10
+ || trimmed.startsWith('./')
11
+ || trimmed.startsWith('../')
12
+ || trimmed.startsWith('~')
13
+ || trimmed.startsWith('file://')
14
+ )
15
+ }
16
+
17
+ export function resolveLocalPath(input: string): string {
18
+ if (input.startsWith('file://')) {
19
+ return fileURLToPath(input)
20
+ }
21
+ if (input.startsWith('~')) {
22
+ return path.join(os.homedir(), input.slice(1))
23
+ }
24
+ return path.resolve(input)
25
+ }
26
+
27
+ function readResponseUrlPath(
28
+ data: Record<string, unknown>,
29
+ pathValue?: string,
30
+ ): string | undefined {
31
+ if (!pathValue)
32
+ return undefined
33
+ const parts = pathValue.split('.').filter(Boolean)
34
+ let current: unknown = data
35
+ for (const part of parts) {
36
+ if (!current || typeof current !== 'object')
37
+ return undefined
38
+ current = (current as Record<string, unknown>)[part]
39
+ }
40
+ return typeof current === 'string' ? current : undefined
41
+ }
42
+
43
+ function extractUploadedUrl(
44
+ responseText: string,
45
+ responseData: Record<string, unknown>,
46
+ pathValue?: string,
47
+ ): 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
66
+ }
67
+
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
76
+ }
77
+
78
+ if (responseText.trim() && /^https?:\/\//i.test(responseText.trim())) {
79
+ return responseText.trim()
80
+ }
81
+ return undefined
82
+ }
83
+
84
+ 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
93
+ }
94
+
95
+ export async function uploadLocalMedia(params: UploadLocalMediaParams): Promise<string> {
96
+ const stats = await stat(params.filePath)
97
+ if (!stats.isFile()) {
98
+ throw new Error(`mediaUrl is not a file: ${params.filePath}`)
99
+ }
100
+
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)
106
+
107
+ if (params.uploadFormFields) {
108
+ for (const [key, value] of Object.entries(params.uploadFormFields)) {
109
+ form.set(key, String(value))
110
+ }
111
+ }
112
+
113
+ const controller = new AbortController()
114
+ const timeoutId = setTimeout(() => controller.abort(), params.requestTimeout)
115
+
116
+ let dispatcher: unknown
117
+ let doFetch: any = globalThis.fetch.bind(globalThis)
118
+
119
+ if (params.allowInsecureTls) {
120
+ 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}`)
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
+ }