@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,247 @@
1
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
+ import { Buffer } from 'node:buffer'
3
+ import { getOpenclawWorkclawRuntime } from '../runtime.js'
4
+
5
+ function sendJson(res: any, statusCode: number, payload: unknown): void {
6
+ res.statusCode = statusCode
7
+ res.setHeader('Content-Type', 'application/json')
8
+ res.end(JSON.stringify(payload))
9
+ }
10
+
11
+ async function readRequestBody(req: any): Promise<string> {
12
+ const chunks: Buffer[] = []
13
+ await new Promise<void>((resolve, reject) => {
14
+ req.on('data', (chunk: any) => {
15
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
16
+ })
17
+ req.on('end', () => resolve())
18
+ req.on('error', (err: unknown) => reject(err))
19
+ })
20
+ return Buffer.concat(chunks).toString('utf-8')
21
+ }
22
+
23
+ /**
24
+ * 构建会话 key
25
+ * 格式: openclaw-workclaw:{accountId}:{userId}
26
+ */
27
+ function buildSessionKey(accountId: string, userId: string): string {
28
+ return `openclaw-workclaw:${accountId}:${userId}`
29
+ }
30
+
31
+ /**
32
+ * 解析会话 key
33
+ */
34
+ function parseSessionKey(sessionKey: string): {
35
+ channel: string
36
+ accountId: string
37
+ userId: string
38
+ } | null {
39
+ const parts = sessionKey.split(':')
40
+ if (parts.length !== 3 || parts[0] !== 'openclaw-workclaw') {
41
+ return null
42
+ }
43
+ return {
44
+ channel: parts[0],
45
+ accountId: parts[1],
46
+ userId: parts[2],
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 重置/开启新会话
52
+ * 通过发送系统事件 /new 到指定会话
53
+ */
54
+ async function resetSession(
55
+ sessionKey: string,
56
+ log?: { info?: (msg: string) => void, error?: (msg: string) => void },
57
+ ): Promise<{ success: boolean, message: string }> {
58
+ try {
59
+ const runtime = getOpenclawWorkclawRuntime()
60
+
61
+ // 使用 system.enqueueSystemEvent 发送 /new 命令
62
+ runtime.system.enqueueSystemEvent('/new', {
63
+ sessionKey,
64
+ contextKey: null,
65
+ })
66
+
67
+ log?.info?.(`[SessionAPI] Sent /new command to session: ${sessionKey}`)
68
+
69
+ return {
70
+ success: true,
71
+ message: `Session reset command sent to ${sessionKey}`,
72
+ }
73
+ }
74
+ catch (err) {
75
+ const errorMsg = `Failed to reset session: ${String(err)}`
76
+ log?.error?.(`[SessionAPI] ${errorMsg}`)
77
+ return { success: false, message: errorMsg }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 获取会话存储路径
83
+ */
84
+ function getSessionStorePath(
85
+ agentId?: string,
86
+ log?: { info?: (msg: string) => void, error?: (msg: string) => void },
87
+ ): string | null {
88
+ try {
89
+ const runtime = getOpenclawWorkclawRuntime()
90
+ const storePath = runtime.channel.session.resolveStorePath(undefined, {
91
+ agentId,
92
+ })
93
+ return storePath
94
+ }
95
+ catch (err) {
96
+ log?.error?.(`[SessionAPI] Failed to resolve store path: ${String(err)}`)
97
+ return null
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 读取会话最后更新时间
103
+ */
104
+ async function getSessionLastUpdated(
105
+ sessionKey: string,
106
+ log?: { info?: (msg: string) => void, error?: (msg: string) => void },
107
+ ): Promise<number | null> {
108
+ try {
109
+ const runtime = getOpenclawWorkclawRuntime()
110
+ const storePath = runtime.channel.session.resolveStorePath()
111
+ const updatedAt = runtime.channel.session.readSessionUpdatedAt({
112
+ storePath,
113
+ sessionKey,
114
+ })
115
+ return updatedAt ?? null
116
+ }
117
+ catch (err) {
118
+ log?.error?.(`[SessionAPI] Failed to read session updated at: ${String(err)}`)
119
+ return null
120
+ }
121
+ }
122
+
123
+ export function createSessionApiHandler(api: OpenClawPluginApi) {
124
+ return async (req: any, res: any) => {
125
+ const method = String(req.method ?? 'GET').toUpperCase()
126
+ const url = new URL(req.url ?? '', 'http://localhost')
127
+ // 移除尾部斜杠并获取路径部分
128
+ const fullPath = url.pathname.replace(/\/+$/, '')
129
+ // 提取子路径(去掉 /openclaw-workclaw/sessions 前缀)
130
+ const subPath = fullPath.replace(/^\/openclaw-workclaw\/sessions/, '').replace(/^\//, '') || '/'
131
+
132
+ const log = {
133
+ info: (msg: string) => api.logger?.info?.(`[SessionAPI] ${msg}`),
134
+ error: (msg: string) => api.logger?.error?.(`[SessionAPI] ${msg}`),
135
+ }
136
+
137
+ log?.info?.(`[SessionAPI] ${method} ${fullPath} (subPath: ${subPath})`)
138
+
139
+ // GET /sessions 或 /sessions/ - 列出会话信息
140
+ if (method === 'GET' && (subPath === '/' || subPath === '')) {
141
+ const sessionKey = url.searchParams.get('sessionKey')
142
+
143
+ if (sessionKey) {
144
+ // 获取特定会话信息
145
+ const parsed = parseSessionKey(sessionKey)
146
+ const updatedAt = await getSessionLastUpdated(sessionKey, log)
147
+
148
+ sendJson(res, 200, {
149
+ ok: true,
150
+ session: {
151
+ sessionKey,
152
+ parsed,
153
+ lastUpdatedAt: updatedAt,
154
+ lastUpdatedAtFormatted: updatedAt
155
+ ? new Date(updatedAt).toISOString()
156
+ : null,
157
+ },
158
+ })
159
+ return
160
+ }
161
+
162
+ // 返回 API 信息
163
+ sendJson(res, 200, {
164
+ ok: true,
165
+ message: 'Session Management API',
166
+ endpoints: {
167
+ 'GET /sessions': '获取会话信息 (可选参数: sessionKey)',
168
+ 'POST /sessions/reset': '重置/开启新会话 (参数: accountId + userId 或直接提供 sessionKey)',
169
+ 'GET /sessions/store-path': '获取会话存储路径 (可选参数: agentId)',
170
+ },
171
+ })
172
+ return
173
+ }
174
+
175
+ // POST /sessions/reset - 重置会话
176
+ if (method === 'POST' && subPath === 'reset') {
177
+ const raw = await readRequestBody(req)
178
+ let input: any = {}
179
+ try {
180
+ input = raw ? JSON.parse(raw) : {}
181
+ }
182
+ catch {
183
+ sendJson(res, 400, { ok: false, error: 'Invalid JSON' })
184
+ return
185
+ }
186
+
187
+ const { accountId, userId, sessionKey: directSessionKey } = input
188
+
189
+ let sessionKey: string
190
+ if (directSessionKey) {
191
+ sessionKey = directSessionKey
192
+ }
193
+ else if (accountId && userId) {
194
+ sessionKey = buildSessionKey(accountId, userId)
195
+ }
196
+ else {
197
+ sendJson(res, 400, {
198
+ ok: false,
199
+ error: 'Missing required fields: either provide \'sessionKey\' or both \'accountId\' and \'userId\'',
200
+ })
201
+ return
202
+ }
203
+
204
+ const result = await resetSession(sessionKey, log)
205
+
206
+ if (result.success) {
207
+ sendJson(res, 200, {
208
+ ok: true,
209
+ message: result.message,
210
+ sessionKey,
211
+ })
212
+ }
213
+ else {
214
+ sendJson(res, 500, {
215
+ ok: false,
216
+ error: result.message,
217
+ sessionKey,
218
+ })
219
+ }
220
+ return
221
+ }
222
+
223
+ // GET /sessions/store-path - 获取会话存储路径
224
+ if (method === 'GET' && subPath === 'store-path') {
225
+ const agentId = url.searchParams.get('agentId') ?? undefined
226
+ const storePath = getSessionStorePath(agentId, log)
227
+
228
+ if (storePath) {
229
+ sendJson(res, 200, {
230
+ ok: true,
231
+ storePath,
232
+ agentId: agentId ?? 'default',
233
+ })
234
+ }
235
+ else {
236
+ sendJson(res, 500, {
237
+ ok: false,
238
+ error: 'Failed to resolve session store path',
239
+ })
240
+ }
241
+ return
242
+ }
243
+
244
+ // 404
245
+ sendJson(res, 404, { ok: false, error: 'Not Found' })
246
+ }
247
+ }
@@ -0,0 +1,74 @@
1
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
+ import { exec } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+
5
+ const execAsync = promisify(exec)
6
+
7
+ function sendJson(res: any, statusCode: number, payload: unknown): void {
8
+ res.statusCode = statusCode
9
+ res.setHeader('Content-Type', 'application/json')
10
+ res.end(JSON.stringify(payload))
11
+ }
12
+
13
+ export function createSkillsApiHandler(api: OpenClawPluginApi) {
14
+ return async (req: any, res: any) => {
15
+ const method = String(req.method ?? 'GET').toUpperCase()
16
+
17
+ if (method === 'GET') {
18
+ try {
19
+ // 调用 openclaw skills list 命令
20
+ const { stdout, stderr } = await execAsync('openclaw skills list --json', {
21
+ timeout: 10000, // 10秒超时
22
+ })
23
+
24
+ if (stderr && !stdout) {
25
+ api.logger.error(`Skills list error: ${stderr}`)
26
+ sendJson(res, 500, { ok: false, error: 'Failed to list skills' })
27
+ return
28
+ }
29
+
30
+ // 尝试解析JSON输出
31
+ let skillsData: any
32
+ try {
33
+ skillsData = JSON.parse(stdout)
34
+ }
35
+ catch {
36
+ // 如果不支持--json输出,尝试解析文本输出
37
+ const lines = stdout.split('\n')
38
+ const skills = lines
39
+ .filter((line: string) => line.trim() && !line.includes('Skills'))
40
+ .map((line: string) => {
41
+ const parts = line.split(/\s{2,}/).map((p: string) => p.trim())
42
+ if (parts.length >= 3) {
43
+ return {
44
+ status: parts[0]?.includes('✓') ? 'ready' : 'missing',
45
+ name: parts[1],
46
+ description: parts[2],
47
+ source: parts[3] || 'openclaw-bundled',
48
+ }
49
+ }
50
+ return null
51
+ })
52
+ .filter((s: any) => s !== null)
53
+
54
+ skillsData = {
55
+ total: skills.length,
56
+ ready: skills.filter((s: any) => s.status === 'ready').length,
57
+ missing: skills.filter((s: any) => s.status === 'missing').length,
58
+ skills,
59
+ }
60
+ }
61
+
62
+ sendJson(res, 200, { ok: true, ...skillsData })
63
+ return
64
+ }
65
+ catch (error: any) {
66
+ api.logger.error(`Skills API error: ${error.message}`)
67
+ sendJson(res, 500, { ok: false, error: error.message })
68
+ return
69
+ }
70
+ }
71
+
72
+ sendJson(res, 405, { ok: false, error: 'Method Not Allowed' })
73
+ }
74
+ }
@@ -0,0 +1,43 @@
1
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+
6
+ export function resolveWorkspaceDir(api: OpenClawPluginApi): string {
7
+ const agents = api.config.agents?.list ?? []
8
+ const defaultAgent
9
+ = agents.find(agent => agent?.default === true)
10
+ ?? agents.find(agent => String(agent?.id ?? '').toLowerCase() === 'main')
11
+
12
+ const workspaceRaw
13
+ = (typeof defaultAgent?.workspace === 'string' && defaultAgent.workspace.trim())
14
+ || (typeof api.config.agents?.defaults?.workspace === 'string'
15
+ && api.config.agents.defaults.workspace.trim())
16
+
17
+ if (workspaceRaw) {
18
+ return api.resolvePath(workspaceRaw)
19
+ }
20
+
21
+ const profile = process.env.OPENCLAW_PROFILE?.trim()
22
+ const suffix
23
+ = profile && profile.toLowerCase() !== 'default' ? `workspace-${profile}` : 'workspace'
24
+ return path.join(os.homedir(), '.openclaw', suffix)
25
+ }
26
+
27
+ export function isDefaultWorkspace(api: OpenClawPluginApi): boolean {
28
+ const agents = api.config.agents?.list ?? []
29
+ const defaultAgent
30
+ = agents.find(agent => agent?.default === true)
31
+ ?? agents.find(agent => String(agent?.id ?? '').toLowerCase() === 'main')
32
+
33
+ const workspaceRaw
34
+ = (typeof defaultAgent?.workspace === 'string' && defaultAgent.workspace.trim())
35
+ || (typeof api.config.agents?.defaults?.workspace === 'string'
36
+ && api.config.agents.defaults.workspace.trim())
37
+
38
+ if (workspaceRaw)
39
+ return false
40
+
41
+ const profile = process.env.OPENCLAW_PROFILE?.trim()
42
+ return !profile || profile.toLowerCase() === 'default'
43
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,227 @@
1
+ import type {
2
+ ChannelGatewayContext,
3
+ ChannelPlugin,
4
+ OpenClawConfig,
5
+ } from 'openclaw/plugin-sdk'
6
+ import type { OpenclawWorkclawConfig, ResolvedOpenclawWorkclawAccount } from './types.js'
7
+ import { appendFileSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import {
11
+ isOpenclawWorkclawAccountConfigured,
12
+ listOpenclawWorkclawAccountIds,
13
+ resolveOpenclawWorkclawAccount,
14
+ } from './accounts.js'
15
+ import { OpenclawWorkclawConfigSchema } from './config-schema.js'
16
+ import { startOpenclawWorkclawGateway, stopOpenclawWorkclawGateway } from './gateway/workclaw-gateway.js'
17
+ import { sendMessageOpenclawWorkclaw } from './outbound/index.js'
18
+ import { getOpenclawWorkclawRuntime } from './runtime.js'
19
+
20
+ const DEFAULT_ACCOUNT_ID = 'default'
21
+
22
+ function buildChannelConfigSchema(schema: unknown): any {
23
+ return schema
24
+ }
25
+
26
+ function formatPairingApproveHint(_channelId: string): string {
27
+ return '请回复验证码完成配对'
28
+ }
29
+
30
+ const meta = {
31
+ id: 'openclaw-workclaw',
32
+ label: '智小途',
33
+ selectionLabel: '智小途',
34
+ docsPath: '/channels/openclaw-workclaw',
35
+ docsLabel: '智小途',
36
+ blurb: '智小途 OpenClaw通道',
37
+ aliases: [],
38
+ order: 90,
39
+ }
40
+
41
+ /**
42
+ * 写入文件日志到磁盘
43
+ * @param message 日志消息
44
+ */
45
+ function writeFileLog(message: string): void {
46
+ const timestamp = new Date().toISOString()
47
+ const logLine = `[${timestamp}] ${message}\n`
48
+ const logDir = join(homedir(), '.openclaw', 'logs')
49
+ const logFile = join(logDir, 'sendtext-outbound.log')
50
+
51
+ try {
52
+ appendFileSync(logFile, logLine)
53
+ }
54
+ catch (err) {
55
+ console.error(`[writeFileLog] Failed to write log: ${err}`)
56
+ }
57
+ }
58
+
59
+ export const openclawWorkclawDock = {
60
+ id: 'openclaw-workclaw',
61
+ capabilities: {
62
+ chatTypes: ['direct', 'group'],
63
+ media: true,
64
+ blockStreaming: true,
65
+ },
66
+ }
67
+
68
+ export const openclawWorkclawPlugin: ChannelPlugin<ResolvedOpenclawWorkclawAccount> = {
69
+ id: 'openclaw-workclaw',
70
+ meta,
71
+ capabilities: {
72
+ chatTypes: ['direct', 'group'],
73
+ media: true,
74
+ reactions: false,
75
+ threads: false,
76
+ polls: false,
77
+ nativeCommands: false,
78
+ blockStreaming: true,
79
+ },
80
+ reload: { configPrefixes: ['channels.openclaw-workclaw'] },
81
+ configSchema: buildChannelConfigSchema(OpenclawWorkclawConfigSchema),
82
+ config: {
83
+ listAccountIds: cfg => listOpenclawWorkclawAccountIds(cfg as OpenClawConfig),
84
+ resolveAccount: (cfg, accountId) =>
85
+ resolveOpenclawWorkclawAccount({ cfg: cfg as OpenClawConfig, accountId }),
86
+ isConfigured: account => isOpenclawWorkclawAccountConfigured(account.config),
87
+ describeAccount: account => ({
88
+ accountId: account.accountId,
89
+ name: account.name,
90
+ enabled: account.enabled,
91
+ configured: isOpenclawWorkclawAccountConfigured(account.config),
92
+ }),
93
+ },
94
+ security: {
95
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
96
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID
97
+ const channelConfig = cfg.channels?.['openclaw-workclaw'] as OpenclawWorkclawConfig | undefined
98
+ const useAccountPath = Boolean(channelConfig?.accounts?.[resolvedAccountId])
99
+ const basePath = useAccountPath
100
+ ? `channels.openclaw-workclaw.accounts.${resolvedAccountId}.`
101
+ : 'channels.openclaw-workclaw.'
102
+ return {
103
+ policy: account.config.dmPolicy ?? 'pairing',
104
+ allowFrom: account.config.allowFrom ?? [],
105
+ policyPath: `${basePath}dmPolicy`,
106
+ allowFromPath: `${basePath}allowFrom`,
107
+ approveHint: formatPairingApproveHint('openclaw-workclaw'),
108
+ normalizeEntry: raw => raw.replace(/^openclaw-workclaw:/i, ''),
109
+ }
110
+ },
111
+ },
112
+ status: {
113
+ buildAccountSnapshot: async ({ account }) => {
114
+ const { getOpenclawWorkclawWsConnection } = await import('./runtime.js')
115
+ const ws = getOpenclawWorkclawWsConnection(account.accountId)
116
+ const isConnected = ws && ws.readyState === 1 // WebSocket.OPEN = 1
117
+
118
+ return {
119
+ accountId: account.accountId,
120
+ name: account.name,
121
+ enabled: account.enabled,
122
+ configured: account.configured,
123
+ connected: isConnected,
124
+ running: account.enabled && account.configured,
125
+ mode: account.config.connectionMode || 'workclaw',
126
+ }
127
+ },
128
+ },
129
+ outbound: {
130
+ deliveryMode: 'direct',
131
+ chunker: (text: string, _limit: number) => [text],
132
+ textChunkLimit: 4096,
133
+ sendText: async ({ to, text, accountId, cfg, replyToId }) => {
134
+ writeFileLog(`outbound sendText called: replyToId=${replyToId}, to=${to}, text=${text.substring(0, 100)}`)
135
+ const result = await sendMessageOpenclawWorkclaw({
136
+ accountId: accountId ?? undefined,
137
+ cfg: cfg as OpenClawConfig,
138
+ to,
139
+ text,
140
+ })
141
+ return {
142
+ channel: 'openclaw-workclaw',
143
+ ok: true,
144
+ messageId: result.messageId,
145
+ }
146
+ },
147
+ sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
148
+ if (!mediaUrl && !text.trim()) {
149
+ throw new Error('mediaUrl is required for outbound media')
150
+ }
151
+ const runtime = getOpenclawWorkclawRuntime() as any
152
+ const log = runtime.log ?? console.warn
153
+ if (typeof log === 'function') {
154
+ log(`outbound sendMedia called for ${to} with text ${text} and mediaUrl ${mediaUrl}`)
155
+ }
156
+ else if (log?.info) {
157
+ log.info(`outbound sendMedia called for ${to} with text ${text} and mediaUrl ${mediaUrl}`)
158
+ }
159
+ const messageText = text.trim() ? text : mediaUrl ?? ''
160
+ const result = await sendMessageOpenclawWorkclaw({
161
+ accountId: accountId ?? undefined,
162
+ cfg: cfg as OpenClawConfig,
163
+ to,
164
+ text: messageText,
165
+ mediaUrl: mediaUrl ?? undefined,
166
+ })
167
+ return {
168
+ channel: 'openclaw-workclaw',
169
+ ok: true,
170
+ messageId: result.messageId,
171
+ }
172
+ },
173
+ },
174
+ gateway: {
175
+ startAccount: async (ctx: ChannelGatewayContext<ResolvedOpenclawWorkclawAccount>) => {
176
+ const { setStatus, getStatus, account, accountId, cfg, log } = ctx
177
+ log?.info?.(`startAccount called for ${accountId}`)
178
+
179
+ await startOpenclawWorkclawGateway({
180
+ accountId,
181
+ account,
182
+ cfg,
183
+ log,
184
+ })
185
+
186
+ setStatus({
187
+ ...getStatus(),
188
+ running: true,
189
+ lastStartAt: Date.now(),
190
+ lastError: null,
191
+ })
192
+ log?.info?.(`startAccount completed for ${accountId}, keeping pending until abort`)
193
+
194
+ // 保持 pending 状态直到 abortSignal 触发(防止 OpenClaw 3.8 认为启动立即退出)
195
+ await new Promise<void>((resolve) => {
196
+ if (ctx.abortSignal.aborted) {
197
+ resolve()
198
+ return
199
+ }
200
+ ctx.abortSignal.addEventListener('abort', () => resolve(), { once: true })
201
+ })
202
+
203
+ // abort 触发后,清理资源
204
+ log?.info?.(`startAccount abort signal received for ${accountId}`)
205
+ const wsStrategy = (account.config as any)?.wsConnectionStrategy || 'per-account'
206
+ stopOpenclawWorkclawGateway(accountId, wsStrategy)
207
+ setStatus({
208
+ ...getStatus(),
209
+ running: false,
210
+ lastStopAt: Date.now(),
211
+ })
212
+ },
213
+ stopAccount: async (ctx: ChannelGatewayContext<ResolvedOpenclawWorkclawAccount>) => {
214
+ const { setStatus, getStatus, accountId, account } = ctx
215
+
216
+ // Stop WorkClaw gateway
217
+ const wsStrategy = (account.config as any)?.wsConnectionStrategy || 'per-account'
218
+ stopOpenclawWorkclawGateway(accountId, wsStrategy)
219
+
220
+ setStatus({
221
+ ...getStatus(),
222
+ running: false,
223
+ lastStopAt: Date.now(),
224
+ })
225
+ },
226
+ },
227
+ }
@@ -0,0 +1,110 @@
1
+ import { z } from 'zod'
2
+
3
+ export { z }
4
+
5
+ const DmPolicySchema = z.enum(['open', 'pairing', 'allowlist'])
6
+ const OpenclawWorkclawConnectionModeSchema = z.enum(['websocket'])
7
+ const WsConnectionStrategySchema = z.enum(['per-account', 'per-appKey'])
8
+
9
+ const DmConfigSchema = z
10
+ .object({
11
+ enabled: z.boolean().optional(),
12
+ systemPrompt: z.string().optional(),
13
+ })
14
+ .strict()
15
+ .optional()
16
+
17
+ // Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
18
+ const RenderModeSchema = z.enum(['auto', 'raw', 'card']).optional()
19
+ const ChannelHeartbeatVisibilitySchema = z
20
+ .object({
21
+ visibility: z.enum(['visible', 'hidden']).optional(),
22
+ intervalMs: z.number().int().positive().optional(),
23
+ })
24
+ .strict()
25
+ .optional()
26
+
27
+ /**
28
+ * Per-account config schema
29
+ * Only fields that make sense to configure per-account.
30
+ */
31
+ export const openclawWorkclawAccountConfigSchema = z
32
+ .object({
33
+ enabled: z.boolean().optional(),
34
+ name: z.string().optional(),
35
+ agentId: z.union([z.string(), z.number()]).optional(),
36
+ userId: z.union([z.string(), z.number()]).optional(),
37
+ openConversationId: z.string().optional(),
38
+ replyEndpoint: z.string().optional(),
39
+ pushEndpoint: z.string().optional(),
40
+ dmPolicy: DmPolicySchema.optional(),
41
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
42
+ requireMention: z.boolean().optional(),
43
+ historyLimit: z.number().int().min(0).optional(),
44
+ dmHistoryLimit: z.number().int().min(0).optional(),
45
+ dms: z.record(z.string(), DmConfigSchema).optional(),
46
+ textChunkLimit: z.number().int().positive().optional(),
47
+ chunkMode: z.enum(['length', 'newline']).optional(),
48
+ mediaMaxMb: z.number().positive().optional(),
49
+ heartbeat: ChannelHeartbeatVisibilitySchema,
50
+ renderMode: RenderModeSchema,
51
+ })
52
+ .strict()
53
+
54
+ /**
55
+ * Top-level plugin config schema
56
+ */
57
+ export const OpenclawWorkclawConfigSchema = z
58
+ .object({
59
+ enabled: z.boolean().optional(),
60
+ connectionMode: OpenclawWorkclawConnectionModeSchema.optional().default('websocket'),
61
+ wsConnectionStrategy: WsConnectionStrategySchema.optional().default('per-account'),
62
+ baseUrl: z.string().url().optional(),
63
+ websocketUrl: z.string().url().optional(),
64
+ appKey: z.string().optional(),
65
+ appSecret: z.string().optional(),
66
+ agentId: z.union([z.string(), z.number()]).optional(),
67
+ localIp: z.string().optional(),
68
+ userId: z.union([z.string(), z.number()]).optional(),
69
+ replyEndpoint: z.string().optional(),
70
+ pushEndpoint: z.string().optional(),
71
+ requestTimeout: z.number().int().positive().optional(),
72
+ allowInsecureTls: z.boolean().optional(),
73
+ allowRawJsonPayload: z.boolean().optional(),
74
+ capabilities: z.array(z.string()).optional(),
75
+ uploadUrl: z.string().url().optional(),
76
+ uploadFieldName: z.string().optional(),
77
+ uploadHeaders: z.record(z.string(), z.string()).optional(),
78
+ uploadFormFields: z
79
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
80
+ .optional(),
81
+ uploadResponseUrlPath: z.string().optional(),
82
+ dmPolicy: DmPolicySchema.optional().default('pairing'),
83
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
84
+ historyLimit: z.number().int().min(0).optional(),
85
+ dmHistoryLimit: z.number().int().min(0).optional(),
86
+ dms: z.record(z.string(), DmConfigSchema).optional(),
87
+ textChunkLimit: z.number().int().positive().optional(),
88
+ chunkMode: z.enum(['length', 'newline']).optional(),
89
+ mediaMaxMb: z.number().positive().optional(),
90
+ heartbeat: ChannelHeartbeatVisibilitySchema,
91
+ renderMode: RenderModeSchema,
92
+ accounts: z.record(z.string(), openclawWorkclawAccountConfigSchema.optional()).optional(),
93
+ })
94
+ .strict()
95
+ .superRefine((value, ctx) => {
96
+ if (value.dmPolicy === 'open') {
97
+ const allowFrom = value.allowFrom ?? []
98
+ const hasWildcard = allowFrom.some(entry => String(entry).trim() === '*')
99
+ if (!hasWildcard) {
100
+ ctx.addIssue({
101
+ code: z.ZodIssueCode.custom,
102
+ path: ['allowFrom'],
103
+ message:
104
+ 'channels.openclaw-workclaw.dmPolicy="open" requires channels.openclaw-workclaw.allowFrom to include "*"',
105
+ })
106
+ }
107
+ }
108
+ })
109
+
110
+ export const openclawWorkclawConfigSchema = OpenclawWorkclawConfigSchema