@2en/clawly-plugins 0.1.0 → 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 (4) hide show
  1. package/index.ts +8 -3
  2. package/outbound.ts +103 -0
  3. package/package.json +2 -5
  4. package/file.ts +0 -161
package/index.ts CHANGED
@@ -2,10 +2,13 @@
2
2
  * OpenClaw plugin: Clawly utility RPC methods (clawly.*).
3
3
  *
4
4
  * Gateway methods:
5
- * - clawly.file.get — read a local file and return base64-encoded content
5
+ * - clawly.file.getOutbound — read a persisted outbound file by original-path hash
6
+ *
7
+ * Hooks:
8
+ * - tool_result_persist — copies TTS audio to persistent outbound directory
6
9
  */
7
10
 
8
- import {registerFileMethods} from './file'
11
+ import {registerOutboundHook, registerOutboundMethods} from './outbound'
9
12
 
10
13
  type PluginRuntime = {
11
14
  state?: {
@@ -30,6 +33,7 @@ export type PluginApi = {
30
33
  respond: (ok: boolean, payload?: unknown, error?: {code?: string; message?: string}) => void
31
34
  }) => Promise<void> | void,
32
35
  ) => void
36
+ on: (hookName: string, handler: (...args: any[]) => any, opts?: {priority?: number}) => void
33
37
  }
34
38
 
35
39
  export default {
@@ -37,7 +41,8 @@ export default {
37
41
  name: 'Clawly Plugins',
38
42
  description: 'Clawly utility RPC methods (clawly.*).',
39
43
  register(api: PluginApi) {
40
- registerFileMethods(api)
44
+ registerOutboundHook(api)
45
+ registerOutboundMethods(api)
41
46
  api.logger.info(`Loaded ${api.id} plugin.`)
42
47
  },
43
48
  }
package/outbound.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * TTS outbound persistence — copies ephemeral TTS audio to a persistent directory
3
+ * so mobile can fetch it after the temp files are cleaned up.
4
+ *
5
+ * Hook: tool_result_persist → copies TTS audioPath to ~/.openclaw/clawly/outbound/<hash>.<ext>
6
+ * Method: clawly.file.getOutbound → reads outbound file by original-path hash
7
+ */
8
+
9
+ import crypto from 'node:crypto'
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+
14
+ import type {PluginApi} from './index'
15
+
16
+ const OUTBOUND_DIR = path.join(os.homedir(), '.openclaw', 'clawly', 'outbound')
17
+
18
+ function hashPath(filePath: string): string {
19
+ return crypto.createHash('sha256').update(filePath).digest('hex')
20
+ }
21
+
22
+ function outboundFilePath(originalPath: string): string {
23
+ const hash = hashPath(originalPath)
24
+ const ext = path.extname(originalPath) // e.g. ".mp3"
25
+ return path.join(OUTBOUND_DIR, `${hash}${ext}`)
26
+ }
27
+
28
+ function ensureOutboundDir(): void {
29
+ fs.mkdirSync(OUTBOUND_DIR, {recursive: true})
30
+ }
31
+
32
+ // ── Hook: tool_result_persist ───────────────────────────────────────
33
+
34
+ export function registerOutboundHook(api: PluginApi) {
35
+ api.on(
36
+ 'tool_result_persist',
37
+ (event: {toolName?: string; message?: {details?: {audioPath?: string}}}) => {
38
+ if (event.toolName !== 'tts') return
39
+ const audioPath = event.message?.details?.audioPath
40
+ if (!audioPath) return
41
+
42
+ try {
43
+ const dest = outboundFilePath(audioPath)
44
+ if (fs.existsSync(dest)) return // idempotent
45
+
46
+ if (!fs.existsSync(audioPath)) {
47
+ api.logger.warn(`outbound: source file missing, skipping copy: ${audioPath}`)
48
+ return
49
+ }
50
+
51
+ ensureOutboundDir()
52
+ fs.copyFileSync(audioPath, dest)
53
+ api.logger.info(`outbound: persisted ${audioPath} → ${dest}`)
54
+ } catch (err) {
55
+ api.logger.error(
56
+ `outbound: failed to persist ${audioPath}: ${err instanceof Error ? err.message : String(err)}`,
57
+ )
58
+ }
59
+ },
60
+ )
61
+ }
62
+
63
+ // ── Gateway method: clawly.file.getOutbound ─────────────────────────
64
+
65
+ export function registerOutboundMethods(api: PluginApi) {
66
+ api.registerGatewayMethod('clawly.file.getOutbound', async ({params, respond}) => {
67
+ const rawPath = typeof params.path === 'string' ? params.path.trim() : ''
68
+
69
+ if (!rawPath) {
70
+ respond(false, undefined, {code: 'invalid_params', message: 'path is required'})
71
+ return
72
+ }
73
+
74
+ const dest = outboundFilePath(rawPath)
75
+
76
+ if (!fs.existsSync(dest)) {
77
+ api.logger.warn(`outbound: file not found: ${rawPath} ${dest}`)
78
+ respond(false, undefined, {code: 'not_found', message: 'outbound file not found'})
79
+ return
80
+ }
81
+
82
+ const buffer = fs.readFileSync(dest)
83
+
84
+ const EXT_MIME: Record<string, string> = {
85
+ '.mp3': 'audio/mpeg',
86
+ '.wav': 'audio/wav',
87
+ '.m4a': 'audio/mp4',
88
+ '.ogg': 'audio/ogg',
89
+ '.aac': 'audio/aac',
90
+ '.flac': 'audio/flac',
91
+ '.webm': 'audio/webm',
92
+ }
93
+ const mimeType = EXT_MIME[path.extname(dest).toLowerCase()] ?? 'application/octet-stream'
94
+
95
+ const base64 = buffer.toString('base64')
96
+ const filename = path.basename(rawPath)
97
+
98
+ api.logger.info(
99
+ `clawly.file.getOutbound: served ${filename} (${mimeType}, ${buffer.length} bytes)`,
100
+ )
101
+ respond(true, {base64, mimeType, filename})
102
+ })
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -10,12 +10,9 @@
10
10
  },
11
11
  "files": [
12
12
  "index.ts",
13
- "file.ts",
13
+ "outbound.ts",
14
14
  "openclaw.plugin.json"
15
15
  ],
16
- "dependencies": {
17
- "file-type": "^19.6.0"
18
- },
19
16
  "openclaw": {
20
17
  "extensions": [
21
18
  "./index.ts"
package/file.ts DELETED
@@ -1,161 +0,0 @@
1
- /**
2
- * clawly.file.get — read a file from the local filesystem and return it as base64.
3
- *
4
- * Validates: absolute path, no traversal, MIME whitelist, 50 MB size limit.
5
- */
6
-
7
- import fs from 'node:fs/promises'
8
- import path from 'node:path'
9
- import {fileTypeFromBuffer} from 'file-type'
10
-
11
- import type {PluginApi} from './index'
12
-
13
- const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
14
-
15
- const ALLOWED_MIME_PREFIXES = ['image/', 'video/', 'audio/']
16
-
17
- /** Extensions allowed via fallback when file-type cannot detect MIME (e.g. plain text). */
18
- const DOCUMENT_EXT_WHITELIST = new Set([
19
- 'pdf',
20
- 'doc',
21
- 'docx',
22
- 'xls',
23
- 'xlsx',
24
- 'ppt',
25
- 'pptx',
26
- 'txt',
27
- 'csv',
28
- 'rtf',
29
- ])
30
-
31
- const EXT_TO_MIME: Record<string, string> = {
32
- pdf: 'application/pdf',
33
- doc: 'application/msword',
34
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
35
- xls: 'application/vnd.ms-excel',
36
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
37
- ppt: 'application/vnd.ms-powerpoint',
38
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
39
- txt: 'text/plain',
40
- csv: 'text/csv',
41
- rtf: 'application/rtf',
42
- }
43
-
44
- function getExtension(filePath: string): string {
45
- const dot = filePath.lastIndexOf('.')
46
- return dot === -1 ? '' : filePath.slice(dot + 1).toLowerCase()
47
- }
48
-
49
- function getFilename(filePath: string): string {
50
- return path.basename(filePath)
51
- }
52
-
53
- export function registerFileMethods(api: PluginApi) {
54
- api.registerGatewayMethod('clawly.file.get', async ({params, respond}) => {
55
- const rawPath = typeof params.path === 'string' ? params.path.trim() : ''
56
-
57
- // ── Validate path ──────────────────────────────────────────────
58
- if (!rawPath) {
59
- respond(false, undefined, {code: 'invalid_params', message: 'path is required'})
60
- return
61
- }
62
-
63
- if (!path.isAbsolute(rawPath)) {
64
- respond(false, undefined, {code: 'invalid_params', message: 'path must be absolute'})
65
- return
66
- }
67
-
68
- const normalized = path.normalize(rawPath)
69
-
70
- // Resolve symlinks and verify the real path matches the normalized expectation
71
- let realPath: string
72
- try {
73
- realPath = await fs.realpath(normalized)
74
- } catch (err) {
75
- const code = (err as NodeJS.ErrnoException).code
76
- if (code === 'ENOENT') {
77
- respond(false, undefined, {code: 'not_found', message: 'file not found'})
78
- return
79
- }
80
- api.logger.error(
81
- `clawly.file.get realpath failed: ${err instanceof Error ? err.message : String(err)}`,
82
- )
83
- respond(false, undefined, {code: 'error', message: 'failed to resolve path'})
84
- return
85
- }
86
-
87
- // ── Size check ─────────────────────────────────────────────────
88
- let stat: Awaited<ReturnType<typeof fs.stat>>
89
- try {
90
- stat = await fs.stat(realPath)
91
- } catch (err) {
92
- const code = (err as NodeJS.ErrnoException).code
93
- if (code === 'ENOENT') {
94
- respond(false, undefined, {code: 'not_found', message: 'file not found'})
95
- return
96
- }
97
- api.logger.error(
98
- `clawly.file.get stat failed: ${err instanceof Error ? err.message : String(err)}`,
99
- )
100
- respond(false, undefined, {code: 'error', message: 'failed to stat file'})
101
- return
102
- }
103
-
104
- if (!stat.isFile()) {
105
- respond(false, undefined, {code: 'invalid_params', message: 'path is not a regular file'})
106
- return
107
- }
108
-
109
- if (stat.size > MAX_FILE_SIZE) {
110
- respond(false, undefined, {
111
- code: 'file_too_large',
112
- message: `file exceeds ${MAX_FILE_SIZE / 1024 / 1024} MB limit`,
113
- })
114
- return
115
- }
116
-
117
- // ── Read file ──────────────────────────────────────────────────
118
- let buffer: Buffer
119
- try {
120
- buffer = await fs.readFile(realPath)
121
- } catch (err) {
122
- api.logger.error(
123
- `clawly.file.get readFile failed: ${err instanceof Error ? err.message : String(err)}`,
124
- )
125
- respond(false, undefined, {code: 'error', message: 'failed to read file'})
126
- return
127
- }
128
-
129
- // ── MIME detection ─────────────────────────────────────────────
130
- let mimeType: string | undefined
131
-
132
- const detected = await fileTypeFromBuffer(buffer)
133
- if (detected) {
134
- mimeType = detected.mime
135
- }
136
-
137
- // Check against allowed MIME prefixes
138
- if (mimeType && ALLOWED_MIME_PREFIXES.some((prefix) => mimeType!.startsWith(prefix))) {
139
- // Allowed media type
140
- } else {
141
- // Fallback: check document extension whitelist
142
- const ext = getExtension(realPath)
143
- if (DOCUMENT_EXT_WHITELIST.has(ext)) {
144
- mimeType = EXT_TO_MIME[ext] ?? 'application/octet-stream'
145
- } else {
146
- respond(false, undefined, {
147
- code: 'unsupported_type',
148
- message: `file type not allowed: ${mimeType ?? `unknown (ext: .${ext || '?'})`}`,
149
- })
150
- return
151
- }
152
- }
153
-
154
- // ── Respond ────────────────────────────────────────────────────
155
- const base64 = buffer.toString('base64')
156
- const filename = getFilename(realPath)
157
-
158
- api.logger.info(`clawly.file.get: served ${filename} (${mimeType}, ${buffer.length} bytes)`)
159
- respond(true, {base64, mimeType, filename})
160
- })
161
- }