@2en/clawly-plugins 0.1.0 → 1.1.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.
- package/agent-send.ts +93 -0
- package/echo.ts +21 -0
- package/index.ts +31 -3
- package/notification.ts +130 -0
- package/openclaw.plugin.json +2 -2
- package/outbound.ts +88 -0
- package/package.json +9 -5
- package/presence.ts +51 -0
- package/file.ts +0 -161
package/agent-send.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent messaging gateway methods — send messages to the agent from
|
|
3
|
+
* external callers (cron, other agents) and optionally push-notify
|
|
4
|
+
* when the mobile client is offline.
|
|
5
|
+
*
|
|
6
|
+
* Methods:
|
|
7
|
+
* - clawly.agent.send — run `openclaw agent --message <msg>`
|
|
8
|
+
* - clawly.agent.echo — same, but wraps message as `/clawly.echo <msg>`
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {$} from 'zx'
|
|
12
|
+
import type {PluginApi} from './index'
|
|
13
|
+
import {sendPushNotification} from './notification'
|
|
14
|
+
import {isClientOnline} from './presence'
|
|
15
|
+
|
|
16
|
+
// Suppress zx default stdout logging
|
|
17
|
+
$.verbose = false
|
|
18
|
+
|
|
19
|
+
interface AgentSendParams {
|
|
20
|
+
message: string
|
|
21
|
+
notificationMessage?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AgentSendResult {
|
|
25
|
+
ok: boolean
|
|
26
|
+
online: boolean
|
|
27
|
+
pushSent: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runAgentMessage(
|
|
31
|
+
message: string,
|
|
32
|
+
api: PluginApi,
|
|
33
|
+
): Promise<{ok: boolean; error?: string}> {
|
|
34
|
+
try {
|
|
35
|
+
await $`openclaw agent --agent main --message ${message}`
|
|
36
|
+
return {ok: true}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
39
|
+
api.logger.error(`agent-send: openclaw agent failed — ${msg}`)
|
|
40
|
+
return {ok: false, error: msg}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function handleAgentSend(
|
|
45
|
+
rawMessage: string,
|
|
46
|
+
params: AgentSendParams,
|
|
47
|
+
api: PluginApi,
|
|
48
|
+
): Promise<AgentSendResult> {
|
|
49
|
+
const {ok} = await runAgentMessage(rawMessage, api)
|
|
50
|
+
const online = await isClientOnline()
|
|
51
|
+
|
|
52
|
+
let pushSent = false
|
|
53
|
+
if (!online && params.notificationMessage) {
|
|
54
|
+
pushSent = await sendPushNotification({body: params.notificationMessage}, api)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {ok, online, pushSent}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerAgentSend(api: PluginApi) {
|
|
61
|
+
// clawly.agent.send — send a raw message to the agent
|
|
62
|
+
api.registerGatewayMethod('clawly.agent.send', async ({params, respond}) => {
|
|
63
|
+
const message = typeof params.message === 'string' ? params.message.trim() : ''
|
|
64
|
+
if (!message) {
|
|
65
|
+
respond(false, undefined, {code: 'invalid_params', message: 'message is required'})
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const notificationMessage =
|
|
70
|
+
typeof params.notificationMessage === 'string' ? params.notificationMessage : undefined
|
|
71
|
+
|
|
72
|
+
const result = await handleAgentSend(message, {message, notificationMessage}, api)
|
|
73
|
+
respond(result.ok, result)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// clawly.agent.echo — send an echo-wrapped message (bypasses LLM)
|
|
77
|
+
api.registerGatewayMethod('clawly.agent.echo', async ({params, respond}) => {
|
|
78
|
+
const message = typeof params.message === 'string' ? params.message.trim() : ''
|
|
79
|
+
if (!message) {
|
|
80
|
+
respond(false, undefined, {code: 'invalid_params', message: 'message is required'})
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const notificationMessage =
|
|
85
|
+
typeof params.notificationMessage === 'string' ? params.notificationMessage : undefined
|
|
86
|
+
|
|
87
|
+
const echoMessage = `/clawly_echo ${message}`
|
|
88
|
+
const result = await handleAgentSend(echoMessage, {message, notificationMessage}, api)
|
|
89
|
+
respond(result.ok, result)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
api.logger.info('agent-send: registered clawly.agent.send + clawly.agent.echo methods')
|
|
93
|
+
}
|
package/echo.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Echo command — `/clawly_echo <text>` replies directly without
|
|
3
|
+
* invoking the LLM.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {PluginApi} from './index'
|
|
7
|
+
|
|
8
|
+
export function registerEchoCommand(api: PluginApi) {
|
|
9
|
+
api.registerCommand({
|
|
10
|
+
name: 'clawly_echo',
|
|
11
|
+
description: 'Echo text back without invoking the LLM',
|
|
12
|
+
acceptsArgs: true,
|
|
13
|
+
handler: async (ctx) => {
|
|
14
|
+
const text = ctx.args?.trim() || ''
|
|
15
|
+
if (!text) return {text: 'Usage: /clawly_echo <message>'}
|
|
16
|
+
return {text}
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
api.logger.info('echo: registered /clawly_echo command')
|
|
21
|
+
}
|
package/index.ts
CHANGED
|
@@ -2,10 +2,25 @@
|
|
|
2
2
|
* OpenClaw plugin: Clawly utility RPC methods (clawly.*).
|
|
3
3
|
*
|
|
4
4
|
* Gateway methods:
|
|
5
|
-
* - clawly.file.
|
|
5
|
+
* - clawly.file.getOutbound — read a persisted outbound file by original-path hash
|
|
6
|
+
* - clawly.isOnline — check if a mobile client is connected
|
|
7
|
+
* - clawly.notification.setToken — register Expo push token for offline notifications
|
|
8
|
+
* - clawly.notification.send — send a push notification directly
|
|
9
|
+
* - clawly.agent.send — send a message to the agent (+ optional push)
|
|
10
|
+
* - clawly.agent.echo — echo-wrapped agent message (bypasses LLM)
|
|
11
|
+
*
|
|
12
|
+
* Commands:
|
|
13
|
+
* - /clawly_echo — echo text back without LLM
|
|
14
|
+
*
|
|
15
|
+
* Hooks:
|
|
16
|
+
* - tool_result_persist — copies TTS audio to persistent outbound directory
|
|
6
17
|
*/
|
|
7
18
|
|
|
8
|
-
import {
|
|
19
|
+
import {registerAgentSend} from './agent-send'
|
|
20
|
+
import {registerEchoCommand} from './echo'
|
|
21
|
+
import {registerNotification} from './notification'
|
|
22
|
+
import {registerOutboundHook, registerOutboundMethods} from './outbound'
|
|
23
|
+
import {registerPresence} from './presence'
|
|
9
24
|
|
|
10
25
|
type PluginRuntime = {
|
|
11
26
|
state?: {
|
|
@@ -30,6 +45,14 @@ export type PluginApi = {
|
|
|
30
45
|
respond: (ok: boolean, payload?: unknown, error?: {code?: string; message?: string}) => void
|
|
31
46
|
}) => Promise<void> | void,
|
|
32
47
|
) => void
|
|
48
|
+
on: (hookName: string, handler: (...args: any[]) => any, opts?: {priority?: number}) => void
|
|
49
|
+
registerCommand: (cmd: {
|
|
50
|
+
name: string
|
|
51
|
+
description?: string
|
|
52
|
+
acceptsArgs?: boolean
|
|
53
|
+
requireAuth?: boolean
|
|
54
|
+
handler: (ctx: {args?: string}) => Promise<{text: string}> | {text: string}
|
|
55
|
+
}) => void
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
export default {
|
|
@@ -37,7 +60,12 @@ export default {
|
|
|
37
60
|
name: 'Clawly Plugins',
|
|
38
61
|
description: 'Clawly utility RPC methods (clawly.*).',
|
|
39
62
|
register(api: PluginApi) {
|
|
40
|
-
|
|
63
|
+
registerOutboundHook(api)
|
|
64
|
+
registerOutboundMethods(api)
|
|
65
|
+
registerEchoCommand(api)
|
|
66
|
+
registerPresence(api)
|
|
67
|
+
registerNotification(api)
|
|
68
|
+
registerAgentSend(api)
|
|
41
69
|
api.logger.info(`Loaded ${api.id} plugin.`)
|
|
42
70
|
},
|
|
43
71
|
}
|
package/notification.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expo push notification support — stores the device push token
|
|
3
|
+
* and sends notifications via Expo's push API.
|
|
4
|
+
*
|
|
5
|
+
* Methods:
|
|
6
|
+
* - clawly.notification.setToken({ token }) → { registered: boolean }
|
|
7
|
+
* - clawly.notification.send({ body, title?, data? }) → { sent: boolean }
|
|
8
|
+
*
|
|
9
|
+
* Internal: sendPushNotification({ body, title?, data? })
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
import type {PluginApi} from './index'
|
|
17
|
+
|
|
18
|
+
const TOKEN_DIR = path.join(os.homedir(), '.openclaw', 'clawly')
|
|
19
|
+
const TOKEN_FILE = path.join(TOKEN_DIR, 'expo-push-token.json')
|
|
20
|
+
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'
|
|
21
|
+
|
|
22
|
+
let pushToken: string | null = null
|
|
23
|
+
|
|
24
|
+
function loadPersistedToken(api: PluginApi): void {
|
|
25
|
+
try {
|
|
26
|
+
if (fs.existsSync(TOKEN_FILE)) {
|
|
27
|
+
const data = JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf-8'))
|
|
28
|
+
if (typeof data.token === 'string' && data.token) {
|
|
29
|
+
pushToken = data.token
|
|
30
|
+
api.logger.info(`notification: loaded persisted push token`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
api.logger.warn(
|
|
35
|
+
`notification: failed to load persisted token: ${err instanceof Error ? err.message : String(err)}`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function persistToken(token: string, api: PluginApi): void {
|
|
41
|
+
try {
|
|
42
|
+
fs.mkdirSync(TOKEN_DIR, {recursive: true})
|
|
43
|
+
fs.writeFileSync(TOKEN_FILE, JSON.stringify({token}, null, 2))
|
|
44
|
+
} catch (err) {
|
|
45
|
+
api.logger.warn(
|
|
46
|
+
`notification: failed to persist token: ${err instanceof Error ? err.message : String(err)}`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getPushToken(): string | null {
|
|
52
|
+
return pushToken
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function sendPushNotification(
|
|
56
|
+
opts: {body: string; title?: string; data?: Record<string, unknown>},
|
|
57
|
+
api: PluginApi,
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
if (!pushToken) {
|
|
60
|
+
api.logger.warn('notification: no push token registered, skipping notification')
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(EXPO_PUSH_URL, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {'Content-Type': 'application/json'},
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
to: pushToken,
|
|
70
|
+
sound: 'default',
|
|
71
|
+
title: opts.title ?? 'Clawly',
|
|
72
|
+
body: opts.body,
|
|
73
|
+
data: opts.data,
|
|
74
|
+
}),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
api.logger.error(`notification: push failed — ${res.status} ${res.statusText}`)
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
api.logger.info(`notification: push sent — "${opts.body}"`)
|
|
83
|
+
return true
|
|
84
|
+
} catch (err) {
|
|
85
|
+
api.logger.error(
|
|
86
|
+
`notification: push failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
87
|
+
)
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function registerNotification(api: PluginApi) {
|
|
93
|
+
loadPersistedToken(api)
|
|
94
|
+
|
|
95
|
+
api.registerGatewayMethod('clawly.notification.setToken', async ({params, respond}) => {
|
|
96
|
+
const token = typeof params.token === 'string' ? params.token.trim() : ''
|
|
97
|
+
|
|
98
|
+
if (!token) {
|
|
99
|
+
respond(false, undefined, {code: 'invalid_params', message: 'token is required'})
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pushToken = token
|
|
104
|
+
persistToken(token, api)
|
|
105
|
+
api.logger.info('notification: push token registered')
|
|
106
|
+
respond(true, {registered: true})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
api.registerGatewayMethod('clawly.notification.send', async ({params, respond}) => {
|
|
110
|
+
const body = typeof params.body === 'string' ? params.body.trim() : ''
|
|
111
|
+
|
|
112
|
+
if (!body) {
|
|
113
|
+
respond(false, undefined, {code: 'invalid_params', message: 'body is required'})
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const title = typeof params.title === 'string' ? params.title : undefined
|
|
118
|
+
const data =
|
|
119
|
+
typeof params.data === 'object' && params.data !== null
|
|
120
|
+
? (params.data as Record<string, unknown>)
|
|
121
|
+
: undefined
|
|
122
|
+
|
|
123
|
+
const sent = await sendPushNotification({body, title, data}, api)
|
|
124
|
+
respond(sent, {sent})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
api.logger.info(
|
|
128
|
+
'notification: registered clawly.notification.setToken + clawly.notification.send',
|
|
129
|
+
)
|
|
130
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "clawly-plugins",
|
|
3
3
|
"name": "Clawly Plugins",
|
|
4
|
-
"description": "Clawly utility RPC methods (clawly.*): file access.",
|
|
5
|
-
"version": "0.
|
|
4
|
+
"description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, and agent messaging.",
|
|
5
|
+
"version": "0.2.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/outbound.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
const base64 = buffer.toString('base64')
|
|
84
|
+
|
|
85
|
+
api.logger.info(`clawly.file.getOutbound: served ${rawPath} (${buffer.length} bytes)`)
|
|
86
|
+
respond(true, {base64})
|
|
87
|
+
})
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -8,14 +8,18 @@
|
|
|
8
8
|
"url": "https://github.com/2enai/clawly",
|
|
9
9
|
"directory": "plugins/clawly-plugins"
|
|
10
10
|
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"zx": "npm:zx@8.8.5-lite"
|
|
13
|
+
},
|
|
11
14
|
"files": [
|
|
12
15
|
"index.ts",
|
|
13
|
-
"
|
|
16
|
+
"outbound.ts",
|
|
17
|
+
"echo.ts",
|
|
18
|
+
"presence.ts",
|
|
19
|
+
"notification.ts",
|
|
20
|
+
"agent-send.ts",
|
|
14
21
|
"openclaw.plugin.json"
|
|
15
22
|
],
|
|
16
|
-
"dependencies": {
|
|
17
|
-
"file-type": "^19.6.0"
|
|
18
|
-
},
|
|
19
23
|
"openclaw": {
|
|
20
24
|
"extensions": [
|
|
21
25
|
"./index.ts"
|
package/presence.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Online presence check — queries `system-presence` via the gateway CLI
|
|
3
|
+
* and checks if the mobile client (`openclaw-ios`) is connected.
|
|
4
|
+
*
|
|
5
|
+
* Method: clawly.isOnline({ host? }) → { isOnline: boolean }
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {$} from 'zx'
|
|
9
|
+
|
|
10
|
+
import type {PluginApi} from './index'
|
|
11
|
+
|
|
12
|
+
$.verbose = false
|
|
13
|
+
|
|
14
|
+
const DEFAULT_HOST = 'openclaw-ios'
|
|
15
|
+
|
|
16
|
+
interface PresenceEntry {
|
|
17
|
+
host?: string
|
|
18
|
+
reason?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Shells out to `openclaw gateway call system-presence` and checks
|
|
23
|
+
* whether the given host has a non-"disconnect" entry.
|
|
24
|
+
*/
|
|
25
|
+
export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
const result = await $`openclaw gateway call system-presence --json`
|
|
28
|
+
const output = result.stdout.trim()
|
|
29
|
+
let jsonStr = output
|
|
30
|
+
if (!output.startsWith('[')) {
|
|
31
|
+
jsonStr = output.slice(output.indexOf('\n['))
|
|
32
|
+
}
|
|
33
|
+
console.log({jsonStr})
|
|
34
|
+
const entries: PresenceEntry[] = JSON.parse(jsonStr)
|
|
35
|
+
const entry = entries.find((e) => e.host === host)
|
|
36
|
+
if (!entry) return false
|
|
37
|
+
return entry.reason === 'connect'
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerPresence(api: PluginApi) {
|
|
44
|
+
api.registerGatewayMethod('clawly.isOnline', async ({params, respond}) => {
|
|
45
|
+
const host = typeof params.host === 'string' ? params.host : DEFAULT_HOST
|
|
46
|
+
const isOnline = await isClientOnline(host)
|
|
47
|
+
respond(true, {isOnline})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
api.logger.info('presence: registered clawly.isOnline method')
|
|
51
|
+
}
|
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
|
-
}
|